[클라우드 네이티브 스프링 인 액션] 3-1. 리액티브 스프링: 복원력과 확장성

2025. 7. 7. 17:18·Programming
  • 이 시스템의 또 다른 필수 기능은 도서 구매 기능이다.
  • 새로운 구성 요소는 데이터베이스뿐만 아니라 카탈로그 서비스도 연결한다.
  • 요청당 스레드 모델에서는 각 요청을 처리하기 위해 하나의 스레드를 할당하는데 이때 스레드는 해당 요청만 처리한다.
  • 요청을 처리할 때 데이터베이스 또는 서비스 호출을 해야 하는 경우 스레드는 호출을 한 후 응답을 기다리면서 유휴 상태가 되고 스레드는 블로킹된다.
  • 유휴 시간동안 해당 스레드에 할당된 리소스는 다른 용도로는 사용할 수 없기 때문에 이 리소스를 낭비하게 된다.
  • 리액티브 프로그래밍 패러다임은 이 문제를 해결하고 I/O 연산이 많은 애플리케이션에서 확장성, 탄력성, 비용 효율성을 개선한다.

 

  • 리액티브 애플리케이션은 비동기적이고, 비차단 방식으로 작동하므로 계산 리소스를 보다 효과적으로 사용한다. → 사용한 만큼만 사용료를 지불하는 클라우드에서는 엄청난 장점이 된다.
  • 스레드는 지원 서비스에 호출을 보낸 후에 유휴 상태로 빠지는 대신 곧바로 다른 작업을 실행한다. → 이 경우 동시에 처리할 수 있는 요청이 스레드의 수에 비례하지 않기 때문에 애플리케이션의 확장성이 향상된다.
  • 동일한 양의 계산 리소스를 사용할 때 리액티브 애플리케이션이 보다 더 많은 사용자에게 서비스를 제공할 수 있다.

 

  • 클라우드 네이티브 애플리케이션은 고도의 분산 시스템으로 변경과 실패가 일상적으로 일어나는 동적 환경에 배포된다.
    • 서비스가 동작하지 않으면 어떻게 될까?
    • 대상 서비스로 가는 도중에 요청이 없어지면 어떻게 될까?
    • 응답이 호출자에게 가는 과정에서 문제가 발생하면 어떻게 될까?
    • 이런 상황에서 높은 가용성을 보장할 수 있을까?

 

  • 클라우드로 옮겨가는 이유 중 하나는 복원력 때문이다.
  • 프로덕션 환경에서 안정적이고 복원력 높은 시스템을 갖기 위해 가장 중요한 부분 가운데 하나가 네트워크의 서비스 간 통합이다.
  • 이번에는 리액티브 패러다임을 사용해 클라우드에서 탄력적이고 확장 가능하며 효율적인 애플리케이션을 구축하는 데 중점을 둔다.

 


 

1. 리액터와 스프링의 비동기 및 비차단 아키텍처

  • 리액티브 매니페스토 : 리액티브 시스템을 반응성, 복원성, 탄력성이 높고 메시지가 주도하는 시스템으로 설명한다.
  • 리액티브 프로그래밍의 기본 내용 + 리액티브가 왜 클라우드 네이티브 애플리케이션에 중요한지 + 명령형 프로그램과는 어떻게 다른지

 

 

[1] 요청당 스레드에서 이벤트 루프로

  • `전통적인 애플리케이션` : 하나의 요청에 하나의 스레드 할당 → 응답을 받을 때까지 스레드는 다른 일을 하지 않는다. → 요청당 스레드 모델 → 애플리케이션의 확장성 제약, 계산 리소스 비효율적 사용
  • `리액티브 애플리케이션` : 스레드를 특정 요청에 독점적으로 할당하지 않고 이벤트를 기반으로 비동기적으로 처리한다. → 이벤트 루프 → 확장 쉬움, 계산 리소스 효율적 사용
  • 리액티브 애플리케이션의 필수 기능 중 하나는 제어 흐름이라고도 하는 비차단 배압이다.
    • 데이터를 처리하는 쪽에서 수신 데이터의 양을 제어해 자신이 처리할 수 있는 것 보다 더 많은 데이터를 받는 위험을 낮추는 것
    • 이렇게 하지 않으면 DoS 공격을 받거나 애플리케이션이 느려지거나 실패가 점차로 확장되어 전체 시스템이 멈출 수 있다.
  • 문제점도 존재한다.
    • 이벤트 중심 방식으로 사고 방식을 전환하는 것 외에도 비동기 I/O로 인해 디버깅과 문제 해결이 더 어렵다.

 

 

[2] 프로젝트 리액터: 모노와 플럭스를 갖는 리액티브 스트림

  • 리액티브 스프링은 프로젝트 리액터 기반 : JVM에서 비동기식 비차단 애플리케이션을 구축하기 위한 프레임 워크
  • 개념적으로, 리액티브 스트림은 데이터 파이프라인을 만들기 위해 사용하는 자바 Stream API와 유사하다.
  • 차이점은 자바 스트림은 pull 기반이라 명령형 방식이고, 동기적으로 데이터를 처리한다. / 리액티브 스트림은 push 기반이라 새로운 데이터가 생성되면 생산자로부터 통보를 받기 때문에 소비자는 비동기적으로 데이터를 처리한다.
  • 리액티브 스트림은 생산자/소비자 패러다임에 따라 작동하면 생산자는 퍼블리셔라고도 한다.
    • 생산자는 어딘가에서 사용될 데이터를 생성한다. 리액터는 Mono<T> 및 Flux<T>를 제공한다.
    • `Mono<T>` : 비동기적인 값이 없거나 하나가 있음을 나타낸다. (0..1)
    • `Flux<T>` : 비동기적인 값이 없거나 하나 이상의 시퀀스를 나타낸다. (0..N)
  • 자바 스트림은 `Optinal<Customer>`나 `Collection<Customer>` 같은 객체를 처리한다.
  • 리액티브 스트림에는 `Mono<Customer>`나 `Flux<Customer>`가 있다.
  • 리액티브 스트림 결과는 비어있거나, 하나의 값 혹은 오류일 수 있고 이들 결과는 데이터로 처리된다.
  • 생산자가 모든 데이터를 반환하면 리액티브 스트림이 성공적으로 완료 되었다고 말한다.
  • 소비자는 생산자에 구독 신청을 하고 새로운 데이터가 생성될 때마다 알림을 받기 때문에 구독자 라고도 불린다.
  • 구독 : 소비자는 배압을 정의하는데 생산자에게 자신이 한 번에 처리할 수 있는 데이터의 양을 알려주는 것 → 얼마나 많은 데이터를 받을지에 대란 제어를 소비자 쪽에 둠으로써 소비자가 너무 많은 데이터를 받아 처리하지 못하는 상황에 빠지는 상황을 방지할 수 있따.
  • 리액티브 스트림은 구독자가 있을 때만 활성화된다.
  • 리액터의 다양한 연산자를 사용하면 여러 다른 소스에서 나온 데이터를 결합해 처리할 수 있는 리액티브 스트림을 만들 수 있다.
  • 자바 스트림은 플루언트 API를 사용해 map, flatMap, filter 같은 연산자를 통해 데이터를 처리할 수 있는데, 각 연산자는 이전 단계의 결과는 불가변 상태로 유지하면서 Stream 객체를 새로 만든다.
  • 리액티브 스트림에도 이와 유사하게 비동기적으로 받은 데이털르 처리하기 위해 플루언트 API와 연산자를 사용해 리액티브 스트림을 만들 수 있다.

 

 

[3] 스프링 리액티브 스택 이해

  • 스프링을 사용해 애플리케이션을 만들 때 서블릿 스택과 리액티브 스택 중 하나를 선택할 수 있다.
  • 서블릿 스택은 `동기적 차단식 I/O`와 `요청당 스레드 모델`을 사용해 요청을 처리한다.
  • 반면에 리액티브 스택은 `비동기적 비차단식 I/O`와 `이벤트 루프 모델`을 사용해 요청을 처리한다.
  • `서블릿 스택` : 서블릿 API와 서블릿 컨테이너를 기반으로 한다.
  • `리액티브 모델` : 리액티브 스트림 API와 네티 또는 서블릿 컨테이너를 기반으로 한다.
  • 두 스택 모두 `@RestController 애너테이션`으로 표시된 클래스를 사용하거나 라우터 함수라고도 하는 함수형 엔드포인트를 사용해 RESTful 애플리케이션을 만들 수 있다.
  • 서블릿 스택은 스프링 MVC를 사용하는 반면, 리액티브 스택은 스프링 웹플럭스를 사용한다.

 


 

2. 스프링 웹플럭스와 스프링 데이터 R2DBC를 갖는 리액티브 서버

  • 지금까지 스프링 MVC 및 스프링 데이터 JDBC를 사용해 비리액티브(명령적) 애플리케이션인 카탈로그 서비스에 대해 작업했다.
  • 스프링 웹플럭스와 스프링 데이터 R2DBC를 사용해 리액티브 웹 애플리케이션(주문 서비스)을 구축한다.
  • 주문 서비스는 책을 구매하는 기능을 제공한다.

주문 서비스 API

엔드포인트 HTTP 메서드 요청 본문 상태  응답 본문 설명
/orders POST OrderRequest 200 Order 주어진 책을 수량만큼 새롭게 주문한다.
/orders GET   200 Order[] 모든 주문을 조회한다.

 

 

[1] 스프링 부트를 통한 리액티브 애플리케이션 부트스트래핑

  • order-service라는 이름으로 깃 저장소를 만들고 깃허브에 푸시한다.

  • 의존성 라이브러리
    • `스프링 리액티브 웹` : 스프링 웹 플럭스를 통해 리액티브 웹 애플리케이션을 구축하기 위한 라이브러리를 제공하며 네티를 기본 임베디드 서버로 포함한다.
    • `스프링 데이터 R2DBC` : 리액티브 애플리케이션에서 스프링 데이터를 사용해 R2DBC로 관계형 데이터베이스에 데이터를 저장하기 위해 필요한 라이브러리를 제공한다.
    • `유효성 검사` : 자바 빈 유효성 검사 API를 사용해 객체의 유효성 검사를 할 때 필요한 라이브러리를 제공한다.
    • `PostgreSQL` : 애플리케이션이 PostgreSQL 데이터베이스에 리액티브 방식으로 연결할 수 있게 해주는 R2DBC 드라이버를 제공한다.
    • `스프링 부트 테스트` : 애플리케이션을 테스트할 수 있는 여러 라이브러리 및 유틸리티를 제공한다.
    • `리액터 테스트` : 프로젝트 리액터를 기반으로 작성된 리액티브 애플리케이션을 테스트하기 위한 유틸리티를 제공한다.
    • `테스트 컨테이너` : 경량 도커 컨테이널르 사용해 애플리케이션을 테스트하기 위해 필요한 라이브러리를 제공한다. 특히 R2DBC 드라이버를 지원하는 PostgreSQL용 테스트 컨테이너를 제공한다.
  • 스프링 부트의 리액티브 애플리케이션을 위한 기본 및 권장 임베디드 서버는 리액터 네티이다.
server:
  port: 9002 # 서버가 연결을 받아들이는 포트
  shutdown: graceful # 우아한 종료를 활성화
  netty:
    connection-timeout: 2s # 서버와 TCP 연결을 수립하기 위해 기다리는 시간
    idle-timeout: 15s # 데이터가 전송되지 않는 경우 TCP 연결을 닫기 전에 기다리는 시간

spring:
  application:
    name: order-service
  lifecycle:
    timeout-per-shutdown-phase: 15s # 15초의 우아한 종료 기간을 정의

 

 

[2] 스프링 데이터 R2DBC를 사용한 리액티브 데이터 지속성

  • 스프링 부트 애플리케이션과 데이터베이스 간의 상호작용에 데이터베이스 드라이버, 엔티티, 리포지터리가 관여한다.
  • 카탈로그 서비스와 비교할 때 주문 서비스의 주된 차이점은 데이터베이스 드라이버 유형이다.
  • 자바 애플리케이션에서는 관계형 데이터베이스와 통신하기 위한 드라이버로 보통 JDBC를 사용하지만 JDBC는 리액티브 프로그래밍을 지원하지 않는다.
  • `R2DBC 드라이버` : 모든 주요 데이터베이스 지원
  • 스프링 데이터 R2DBC를 사용하는 스프링 부트와 테스트 컨테이너 같은 프로젝트는 클라이언트도 제공한다.
  • 스프링 데이터 R2DBC 및 PostgreSQL을 사용해 주문 서비스에 대한 도메인 엔티티 및 지속성 계층을 정의한다.

 

 

(1) 주문 서비스에 대한 PostgreSQL 데이터베이스 실행

  • 우선 데이터베이스가 필요하다.
  • 애플리케이션이 느슨하게 결합되도록 `서비스당 데이터베이스 접근법`을 채택한다.
  • PostgreSQL 서버를 사용해 카탈로그 서비스를 위한 `polardb_catalog 데이터베이스`와 오더 서비스를 위한 새로운 `polardb_order 데이터베이스`를 모두 같은 서버에서 관리한다.
  • `polar-deployment` 저장소로 이동 → `docker/postgresql` 폴더를 새로 만든다 → `init.sql` 추가
CREATE DATABASE polardb_catalog;
CREATE DATABASE polardb_order;
  • `docker-compose.yml` 파일 : 초기화 스크립트 로드하도록 PostgreSQL 컨테이너 정의 수정
environment: # POSTGRES_DB 환경 변수에 대해 더 이상 값이 정의되지 않는다.
    - POSTGRES_USER=user
    - POSTGRES_PASSWORD=password
volumes: # 초기화 SQL 스크립트를 컨테이너에 볼륨으로 마운트한다.
    - ./postgresql/init.sql:/docker-entrypoint-initdb.d/init.sql
  • 새로운 설정을 기반으로 한 PostgreSQL 컨테이너를 새로 시작한다.
$ docker compose up -d polar-postgres

 

 

(2) R2DBC를 사용한 데이터베이스 연결

  • 스프링 부트에서는 spring.r2dbc 속성을 통해 리액티브 애플리케이션이 관계형 데이터베이스를 사용할 수 있도록 설정한다.
  • order-service의 `application.yml` 파일 → PostgreSQL과의 연결 설정
spring:
  r2dbc:
    username: user # 해당 데이터베이스에 접근 권한이 있는 유저
    password: password # 유저의 패스워드
    url: r2dbc:postgresql://localhost:5432/polardb_order # 연결하려는 데이터베이스에 대한 R2DBC URL
    pool:
      max-create-connection-time: 2s # 풀에서 연결 객체 하나를 얻을 때 까지 기다릴 수 있는 최대한의 시간
      initial-size: 5 # 연결 풀의 초기 크기
      max-size: 10 # 풀이 최대한으로 가질 수 있는 연결의 수

 

 

(3) 자속성 엔티티 정의

  • 주문 서비스는 주문을 새로 하고 기존 주문을 조회할 수 있는 기능을 제공한다.
  • 주문은 도메인 엔티티다.
  • 비즈니스 로직을 위해 `domain 패키지` 새로 추가 → `Order 자바 레코드` 생성
package com.polarbookshop.orderservice.order.domain;

import java.time.Instant;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Table;

@Table("orders") // Order 객체와 order 테이블 사이의 매핑 설정
public record Order(

        @Id
        Long id, // 엔티티의 기본 키

        String bookIsbn,
        String bookName,
        Double bookPrice,
        Integer quantity,
        OrderStatus status,

        @CreatedDate
        Instant createdDate, // 엔티티가 생성된 시기

        @LastModifiedDate
        Instant lastModifiedDate, // 엔티티가 최종 수정된 시기

        @Version
        int version // 엔티티의 버전 번호
) {

    public static Order of(String bookIsbn, String bookName, Double bookPrice, Integer quantity, OrderStatus status) {
        return new Order(null, bookIsbn, bookName, bookPrice, quantity, status, null, null, 0);
    }

}
  • 엔티티와 관계형 테이블의 매핑 전략은 자바 객체 이름을 소문자로 변환하는 것으로 기본 설정되어 있다. (Order 레코드 → order 테이블)
  • order가 SQL에서 예약어이기 때문에, orders로한다.

 

  • 주문은 여러 단계를 거칠 수 있다.
    • 요청한 책이 카탈로그에 있고 주문이 가능한 상태면 주문 요청은 접수되고 그렇지 않으면 거부된다.
    • 접수된 주문은 이후에 배송 상태로 변경될 수 있다.
    • 이 세 가지 상태를 domain 패키지의 `OrderStatus 열거형`을 통해 정의한다.
package com.polarbookshop.order_service.order.domain;

public enum OrderStatus {
    ACCEPTED,
    REJECTED,
    DISPATCHED
}

 

  • R2DBC 감사 기능은 `@EnableR2dbcAuditing 애너테이션`으로 표시된 설정 클래스를 통해 활성화할 수 있다.
  • config 패키지 생성 → `DataConfig` 클래스 생성
package com.polarbookshop.order_service.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing;

@Configuration // 이 클래스가 스프링 설정을 위한 클래스임을 나타낸다.
@EnableR2dbcAuditing
public class DataConfig { //지속성 엔티티에 대한 R2DBC 감사를 활성화 한다.
}

 

 

(4) 리액티브 리포지터리 사용

  • 스프링 데이터는 프로젝트의 모든 모듈에 대해 리포지터리 추상화를 제공한다.
  • 이전과 다른 점은 리액티브 리포지터리를 사용한다는 점이다.
  • domain 패키지에 ReactiveCrudRepository를 확장하는 `OrderRepository 인터페이스`를 생성한다.
package com.polarbookshop.order_service.order.domain;

import com.polarbookshop.orderservice.order.domain.Order;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;

public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
// CRUD 연산을 제공하는 리액티브 리포지터리가 관리할 엔티티의 유형(Order)과 해당 엔티티의 기본키 유형(Long)을 지정하고 확장한다.
}
  • 주문 서비스 애플리케이션의 기능을 위해서는 ReactiveCrudRepository에서 제공하는 CRUD 연산만으로 충분하기 때문에 사용자 정의 메서드를 따로 추가할 필요는 없다.
  • 하지만, 데이터베이스에 orders 테이블이 아직 없기 때문에 플라이웨이를 통해 테이블을 생성해야 한다.

 

 

(5) 플라이웨이를 사용한 데이터베이스 스키마 관리

  • 스프링 데이터 R2DBC는 JDBC와 마찬가지로 schema.sql 및 data.sql 파일을 통해 데이터 소스를 초기화하는 것을 지원한다.
  • 하지만, 플라이웨이는 아직 R2DBC를 지원하지 않기 때문에 데이터베이스와 통신하기 위해서는 JDBC 드라이벌르 사용해야 한다.
  • 플라이웨이 마이그레이션은 애플리케이션이 시작할 때, 단일 스레드로 실행되기 때문에 마이그레이션에 대해서만 리액티브가 아닌 방식으로 데이터베이스와 통신하더라도 전체 애플리케이션의 확장성과 효율성에 영향을 미치지 않는다.

 

  • order-service의 `build.gradle` 파일 → 플라이웨이, PostgreSQL JDBC 드라이버, JDBC 의존성을 추가한다.
dependencies {
    runtimeOnly 'org.postgresql:postgresql' # 애플리케이션이 PostgreSQL 데이터베이스에 연결할 수 있게 해주는 JDBC 드라이버
    runtimeOnly 'org.postgresql:r2dbc-postgresql'
    runtimeOnly 'org.flywaydb:flyway-core' # 마이그레이션을 통해 데이터베이스 버전 관리할 수 있는 기능 제공
    runtimeOnly 'org.springframework:spring-jdbc'
}

 

  • `main/resource/db/migration` 폴더 → `V1__initial_schema.sql` 파일 생성 → orders 테이블 생성
CREATE TABLE orders ( # orders 테이블 정의
      id                  BIGSERIAL PRIMARY KEY NOT NULL, # id 칼럼을 기본 키로 선언
      book_isbn           varchar(255) NOT NULL,
      book_name           varchar(255),
      book_price          float8,
      quantity            int NOT NULL,
      status              varchar(255) NOT NULL,
      created_date        timestamp NOT NULL,
      last_modified_date  timestamp NOT NULL,
      version             integer NOT NULL
);

 

  • `application.yml` 파일 → 스프링 데이터 R2DBC가 관리하는 데이터베이스와 동일한 데이터베이스를 플라이웨이가 JDBC 드라이버를 통해 연결하도록 설정한다.
spring:
  r2dbc:
    username: user
    password: password
    url: r2dbc:postgresql://localhost:5432/polardb_order
    pool:
      max-create-connection-time: 2s
      initial-size: 5
      max-size: 10
  flyway:
    user: ${spring.r2dbc.username} # R2DBC에 대해 설정한 값과 같은 유저명 사용
    password: ${spring.r2dbc.password} # R2DBC에 대해 설정한 값과 같은 패스워드 사용
    url: jdbc.postgresql://localhost:5432/polardb_order # R2DBC에 대해 설정한 데이터베이스와 같은 데이터베이스를 연결하기 위해 JDBC 드라이버 사용
  • 리액티브 애플리케이션이 도메인 객체를 정의하고 지속성 계층을 추가하는 것은 명령형 애플리케이션과 유사하다.
  • 주된 차이점은 JDBC 대신 R2DBC 드라이버를 사용하고 플라이웨이 설정을 별도로 해야 한다는 점이다.

 

 

[3] 리액티브 스트림을 이용한 비즈니스 로직 구현

  • 스프링 리액티브 스택을 사용하면 비동기식 비차단 애플리케이션을 쉽게 작성할 수 있다.
  • 기본 설정상 스프링 웹 플럭스는 모든 것이 리액티브로 작동한다고 가정한다.
    • 이 가정이 의미하는 바는 프레임워크를 사용해 작업할 때 Mono<T>, Flux<T>와 같은 Publisher<T>를 프레임워크와 주고 받는다는 것이다.
    • 이전에 만들었던 OrderRepository는 비 리액티브에서 하듯이 Optional<Order>와 Collection<Order>를 반환하지 않고 Mono<Order>와 Flux<Order> 객체를 통해 오더에 액세스한다.
  • 여러 개의 주문이 반환될 수 있다면 0개 이상의 주문에 대한 비동기 시퀀스를 나타내는 `Flux<Order>` 객체 사용
  • domain 패키지에 `OrderService 클래스` 생성
package com.polarbookshop.order_service.order.domain;

import com.polarbookshop.orderservice.order.domain.Order;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service // 이 클래스가 스프링에 의해 관리되는 서비스임을 표시하는 스테레오타입 애너테이션
public class OrderService {
    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Flux<Order> getAllOrders() { // 플럭스는 여러 개의 주문을 위해 사용된다.
        return orderRepository.findAll();
    }
}

 

  • 주문을 새로 요청하는 메서드 필요
  • 카탈로그 서비스와 통합이 이루어질 떄까지는 요청된 주문을 무조건 거부하도록 만든다.
  • OrderRepository는 이 `save()` 메서드에서 주문을 데이터베이스에 저장하는데, 먼저 리액티브 스트림을 만들어 `Mono<Order>` 유형의 객체를 전달해야 한다.
  • 주문할 책의 ISBN과 부수가 주어지면 자바 스트림에서 `Stream.of()`로 Strean 객체를 만드는 것과 같은 방식으로 `Mono.just()`로 Mono 객체를 만든다.
  • 모노 객체를 사용해 리액티브 스트림을 만들고 `flatMap()` 연산자를 통해 데이터를 OrderRepository에 전달할 수 있다.
  • `OrderService 클래스`에 비즈니스 로직 구현
package com.polarbookshop.order_service.order.domain;

import com.polarbookshop.orderservice.order.domain.Order;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class OrderService {
    ...
    
    public Mono<Order> submitOrder(String isbn, int quantity) {
        return Mono.just(buildRejectedOrder(isbn, quantity)) // 주문 객체를 가지고 모노를 생성한다.
        .flatMap(orderRepository::save); //리액티브 스트림의 앞 단계에서 비동기적으로 생성된 주문 객체를 데이터베이스에 저장한다.
    }

    public static Order buildRejectedOrder(String bookIsbn, int quantity) {
        // 주문이 거부되면 ISBN 수량, 상태만 지정한다. 스프링 데이터가 식별자, 버전, 감사 메타 데이터를 알아서 처리해준다.
        return Order.of(bookIsbn, null, null, quantity, OrderStatus.REJECTED);
    }
}

 

 

[4] 스프링 웹 플럭스로 REST API 노출

  • 스프링 웹플럭스 애플리케이션에서 RESTful 엔드 포인트를 정의하기 위해서는 `@RestController` 클래스를 사용하거나 함수적 빈을 사용한다.
  • GET 엔드포인트의 경우, 앞에서 정의한 Order 도메인 엔티티를 사용해 Flux<Order> 객체를 반환할 수 있다.
  • 새로 주문하려면 사용자는 원하는 책의 ISBN와 수량을 제공해야 한다.
  • 이 두가지 정보는 데이터 전송 객체(DTO)인 OrderRequest 레코드로 모델링할 수 있다.
  • `order/web` 패키지 생성 → `OrderReqeust 레코드` 정의
package com.polarbookshop.order_service.order.web;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record OrderRequest(
    @NotBlank(message = "The book ISBN must be defined.")
    String isbn, // null 값을 가질 수 없고, 최소한 화이트 스페이스가 아닌 문자를 하나 이상 가져야 한다.

    @NotNull(message = "The book quantity must be defined.")
    @Min(value = 1, message = "You must be at least 1 item.")
    @Max(value = 5, message = "You connot order more than 5 items.")
    Integer quantity // null 값을 가질 수 없고 1~5 사이의 값을 가져야 한다.
) {}

 

  • 동일한 패키지(web)에서 `OrderController` 생성 → 주문 서비스 애플리케이션이 노출할 두 개의 RESTful 엔드포인트를 정의한다.
  • OrderRequest 객체에 대한 유효성 검사 제약을 정의했기 때문에 `@Valid 애너테이션`을 사용하면 메서드가 호출될 때마다 유효성 검사를 수행할 수 있다.
package com.polarbookshop.order_service.order.web;

import com.polarbookshop.order_service.order.domain.Order;
import com.polarbookshop.order_service.order.domain.OrderService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController // 클래스가 스프링 컴포넌트임을 표시하는 스테레오타입 애너테이션, REST 엔드포인트에 대한 핸들러가 정의되는 클래스임을 나타낸다.
@RequestMapping("orders") // 클래스가 핸들러를 제공하는 URI의 루트 패스(/orders)를 식별한다.
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public Flux<Order> getAllOrders() { // Flux는 여러 개의 객체를 위해 사용한다.
        return orderService.getAllOrders();
    }

    @PostMapping
    // OrderRequest 객체를 받아서 유효성 검증을 하고 주문을 생성한다. 생성한 주문을 모노로 반환한다.
    public Mono<Order> submitOrder(@RequestBody @Valid OrderRequest orderRequest) {
        return orderService.submitOrder(orderRequest.isbn(), orderRequest.quantity());
    }
}

 

  • 엔드포인트가 작동하는지 확인한다.
  • 이전에 만든 PostgreSQL 컨테이너가 실행되고 있는지 확인한 후 애플리케이션을 실행한다.
$ ./gradlew bootRun

$ http POST :9002/orders isbn=1234567890 quantity=3

HTTP/1.1 200 OK
Content-Length: 203
Content-Type: application/json

{
    "bookIsbn": "1234567890",
    "bookName": null,
    "bookPrice": null,
    "createdDate": "2025-07-03T08:37:00.126194Z",
    "id": 1,
    "lastModifiedDate": "2025-07-03T08:37:00.126194Z",
    "quantity": 3,
    "status": "REJECTED",
    "version": 1
}
  • 주문을 성공적으로 제출하려면 주문 서비스가 카탈로그 서비스를 통해 주문 가능 여부를 확인하고 주문 처리에 필요한 정보를 가져와야 한다.

 


 

3. 스프링 웹 클라이언트를 사용한 리액티브 클라이언트

  • order-service와 catalog-service 사이에서 HTTP를 사용해 이루어지는 요청/응답 상호작용을 설명한다.
  • 요청을 하는 클라이언트는 응답을 받을 것으로 예상한다.
  • 명령형 애플리케이션에서는 응답을 받을 때까지 스레드가 차단된다.
  • 반면에 리액티브 애플리케이션에서는 스레드가 응답을 기다리지 않기 때문에 다른 작업을 위해 리소스를 효율적으로 사용할 수 있다.
  • 스프링 프레임워크는 HTTP 요청을 수행하기 위한 클라이언트가 번들로 제공되는데, RestTemplate과 WebClient다.
  • `RestTemplate` : 템플릿 메서드 API를 기반으로 차단 방식의 HTTP 요청/응답 상호작용을 위해 원래부터 제공된 스프링 REST 클라이언트다.
  • `WebClient` : RestTemplate의 대안으로 최근에 나왔다. 차단 및 비차단 I/O를 제공하므로 명령형 및 리액티브 애플리케이션 양쪽에서 사용 가능하다. 함수형 플루언트 API를 통해 HTTP 상호작용의 모든 측면을 설정하고 작동할 수 있다.

 

 

[1] 스프링에서 서비스 간 통신

  • 모든 지원 서비스는 자원 바인딩을 통해 애플리케이션에 연결되어야 한다.
  • 데이터베이스의 경우 크리덴셜 및 URL을 스프링 부트의 설정 속성을 통해 지정한다.
  • 지원 서비스가 다른 애플리케이션인 경우에도 이와 비슷하게 URL을 제공해야 한다. (외부화된 설정 원칙에 따라 URL은 하드 코딩된 값이 아니라 설정 가능해야 한다.)
  • `order-service/config` 패키지에 `ClientProperties 레코드` 생성
package com.polarbookshop.order_service.config;

import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.net.URI;

@ConfigurationProperties(prefix = "polar") // 사용자 지정 속성 이름의 프리픽스
public record ClientProperties(
        @NotNull
        URI catalogServiceUri // 카탈로그 서비스의 URI를 지정하는 속성, null 값을 가질 수 없다.
) {}

 

  • `@ConfigurationPropertiesScan 애너테이션`을 사용해 `OrderServiceApplication 클래스`에서 사용자 지정 설정 속성을 활성화한다.
package com.polarbookshop.order_service;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan // 스프링 콘텍스트에 설정 데이터 빈을 로드한다.
public class OrderServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(OrderServiceApplication.class, args);
	}

}

 

  • `application.yml` → 새 속성 값을 추가한다. 로컬 환경에서 실행 중인 카탈로그 서비스 인스턴스의 URI를 사용할 수 있다.
polar:
  catalog-service-uri: "<http://localhost:9001>"

 

 

[2] 데이터 교환 방법에 대한 이해

  • 사용자가 특정 도서를 주문할 때마다 주문 서비스는 카탈로그 서비스를 호출해 주문 가능한 상태인지 확인하고, 제목, 저자, 가격과 같은 세부 사항을 가져와야 한다.
  • 주문 요청은 도서의 ISBN을 통해 이루어진다. 주문 서비스는 주문을 올바르게 처리하기 위해 도서의 ISBN, 제목, 저자, 가격을 알아야 한다.
  • 현재 카탈로그 서비스는 책에 대한 모든 사용 가능한 정보를 반환하게 위해 `/books/{isbn}` 엔드포인트를 제공하고 있다.
  • 두 애플리케이션이 서로 주고 받는 데이터를 어떻게 모델링 할 수 있을까?
    • `공유 라이브러리 생성` : 두 애플리케이션에서 같이 사용되는 클래스를 공유 라이브러리를 만들고 각 프로젝트에서 의존성 라이브러리로 임포트하는 것이다. 이 방법은 두 애플리케이션에서 사용되는 모델이 일관성을 갖게되고 서로 달리지지 않는다. 하지만, 이 방법은 양 쪽의 구현이 공유 라이브러리를 통해 결합된다는 것을 의미한다.
    • `클래스 중복` : 한쪽 애플리케이션에서 사용하는 클래스를 다른 쪽 애플리케이션도 중복으로 가지고 있는 것이다. 이 방법은 두 애플리케이션이 서로 결합되지는 않지만, 한쪽 애플리케이션에서 클래스를 변경하면 다른 쪽 애플리케이션도 변경해야 한다. (spring cloud contract)
  • 폴라 북숍 프로젝트는 두 번째 방법으로 구현한다.

 

  • order-service에 `book 패키지` 생성 → DTO로 사용할 `Book 레코드` 생성 → 주문 처리에만 사용하는 필드를 추가한다.
package com.polarbookshop.orderservice.book;

public record Book(
        String isbn,
        String title,
        String author,
        Double price
) {}
  • 편의를 위해 기본 `/books/{bookIsbn}` 엔드포인트를 사용할 것이기 때문에, 이 엔드포인트를 호출해서 받은 JSON 응답을 주문 서비스의 DTO 클래스로 역직렬화할 때 DTO 클래스의 필드로 매핑되지 않는 JSON 필드는 무시하고 버린다.
  • 주문 서비스의 Book 레코드의 필드명이 카탈로그 서비스에서 정의한 Book 객체의 해당 필드명과 반드시 일치해야 한다.
  • 카탈로그 서비스가 조회하는 방법을 알아본다.

 

 

[3] 웹 클라이언트를 통한 REST 클라이언트 구현

  • WebClient 인스턴스를 만들 수 있는 방법이 여러 가지 있지만, `WebClient.Builder`를 사용한다.
  • config 패키지에 `ClientConfig 클래스` 생성 → ClientProperties에서 제공하는 베이스 URL로 WebClient 빈을 설정한다.
package com.polarbookshop.orderservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class ClientConfig {
    @Bean
    // WebClient 빈을 만들기 위해 스프링 부트가 자동 설정한 객체
    WebClient webClient(ClientProperties clientProperties, WebClient.Builder webClientBuilder) { 
        // WebClient의 베이스 URL을 사용자 정의 속성을 통해 지정한 카탈로그 서비스 URL로 설정한다.
        return webClientBuilder.baseUrl(clientProperties.catalogServiceUri().toString()).build();
    }
}

 

  • book 패키지에 `BookClient 클래스` 생성 → WebClient 빈의 플루언트 API를 통해 카탈로그 서비스의 GET `/books/{bookIsbn}` 엔드포인트로 HTTP 요청을 보낸다.
  • 최종적으로 WebClient는 Mono 퍼블리셔로 포장된 Book 객체를 반환한다.
package com.polarbookshop.orderservice.book;

import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Component
public class BookClient {
    private static final String BOOKS_ROOT_API = "/books/";
    private final WebClient webClient; // 이전에 설정된 WebClient 빈

    public BookClient(WebClient webClient) {
        this.webClient = webClient;
    }

    public Mono<Book> getBookByIsbn(String isbn) {
        return webClient
                .get() // 요청은 GET 메서드를 사용한다.
                .uri(BOOKS_ROOT_API + isbn) // 요청 URI는 /books/{isbn}이다.
                .retrieve() // 요청을 보내고 응답을 받는다.
                .bodyToMono(Book.class); // 받은 객체를 Mono<book>으로 반환한다.
    }
}

 

  • 카탈로그 서비스를 호출해 특정 도서의 자세한 정보를 가져온 결과가 `Mono<Book>` 객체이다.
  • OrderService 클래스의 `submitOrder()` 메서드는 주문을 무조건 거부 상태로 만들기 때문에 이 부분을 변경해야 한다.
package com.polarbookshop.orderservice.order.domain;

import com.polarbookshop.orderservice.book.Book;
import com.polarbookshop.orderservice.book.BookClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class OrderService {
    private final BookClient bookClient;
    private final OrderRepository orderRepository;

    public OrderService(BookClient bookClient, OrderRepository orderRepository) {
        this.bookClient = bookClient;
        this.orderRepository = orderRepository;
    }
    
    ...
    
    public Mono<Order> submitOrder(String isbn, int quantity) {
        return bookClient.getBookByIsbn(isbn) // 카탈로그 서비스를 호출해 책의 주문 가능성을 확인한다.
                .map(book -> buildAcceptedOrder(book, quantity)) // 책 주문이 가능하면 접수한다.
                .defaultIfEmpty(buildRejectedOrder(isbn, quantity)) // 책이 카탈로그에 존재하지 않으면 주문을 거부한다.
                .flatMap(orderRepository::save); // 주문을 저장한다.
    }

    public static Order buildAcceptedOrder(Book book, int quantity) {
        // 주문이 접수되면 ISBN, 책의 이름(제목과 저자), 수량, 상태만 지정하면 스프링 데이터가 식별자, 버전, 감사 메타데이터를 추가한다.
        return Order.of(book.isbn(), book.title() + "-" + book.author(), book.price(), quantity, OrderStatus.ACCEPTED);
    }
    
    ...
}
  • BookClient 인스턴스를 오토와이어링하고 그 내부의 WebClient를 사용해 책 정보를 받아서 처리하고 주문을 생성하는 리액티브 스트림을 시작할 수 있다.
  • `map()` 연산자를 통해 Book을 접수된 주문으로 매핑할 수 있다.
  • 만일 BookClient가 비어있는 결과를 반환하면 `defaultIfEmpty()` 연산자를 통해 거부된 Order로 만들 수 있다.
  • 마지막으로 스트림은 주문을 저장하기 위해 OrderRepository를 호출하고 끝난다.

 

  • 실행한다.
  • polar-deployment/docker 에서 PostgreSQL 컨테이너를 실행한다.
$ docker compose up -d polar-postgres
  • order-service 와 catalog-service 전부 실행
$ ./gradlew bootRun
  • 카탈로그 서비스가 시작할 때 생성한 책 중 하나를 선택해 주문한다. 책이 있다면 그 주문은 접수되어야 한다.
$ http POST :9002/orders isbn=1234567891 quantity=3

HTTP/1.1 200 OK
Content-Length: 230
Content-Type: application/json

{
    "bookIsbn": "1234567891",
    "bookName": "Northen Lights-Lyra Silverstar",
    "bookPrice": 9.9,
    "createdDate": "2025-07-03T11:11:58.792066Z",
    "id": 1,
    "lastModifiedDate": "2025-07-03T11:11:58.792066Z",
    "quantity": 3,
    "status": "ACCEPTED",
    "version": 1
}
  • 카탈로그 서비스 호출을 통해 책이 카탈로그에 존재하면 주문은 접수된다.
  • 빈 결과가 반환되면 주문은 거부된다.
  • 만일, 카탈로그 서비스가 회신하는 데 너무 오래 걸리면?
  • 카탈로그 서비스가 하필이면 그 당시에 일시적으로 작동을 멈추고 새로운 요청을 처리하지 못하면?
  • 카탈로그 서비스가 오류를 응답으로 보내면?

 


 

4. 리액티브 스프링을 통한 복원력 높은 애플리케이션

  • 복원력은 장애가 발생하더라도 시스템을 계속 사용할 수 있게 유지하면서 서비스를 제공할 수 있는 속성이다.
  • 복원력을 달성하는 데 중요한 점은 문제가 해결될 떄까지 해당 구성 요소를 격리하는 것이다. → 균열 전파를 막을 수 있다.
  • 카탈로그 서비스에 오류가 발생해 응답하지 않을 때 이것이 주문 서비스에 영향을 끼쳐서는 안된다.

 

 

[1] 타임아웃

  • `타임아웃` : 적절한 시간 내에 응답을 받지 않더라도 애플리케이션의 응답성을 유지하기 위한 간단하면서 효과적인 도구이다.
  • 타임아웃을 설정하는 이유
    • 클라이드가 기다리는 시간을 제한하지 않으면 계산 자원이 너무 오랫동안 차단될 위험이 있다. 최악의 경우 원격 서비스의 응답을 기다리느라 모든 가용 스레드가 차단되어 새로운 요청을 처리할 스레드가 없어 애플리케이션이 완전히 응답하지 못할 수 있다.
    • 서비스 수준 협약(SLA)을 충족하지 못하면 응답을 기다릴 이유가 없고, 요청을 실패 처리하는 것이 더 낫다.
  • 타임아웃의 예
    • `연결 타임아웃 (connection timeout)` : 원격 자원과 통신 채널을 수립하는 데 걸리는 시간에 대한 제한이다. server.netty.connection-timeout 속성으로 네티가 TCP 연결을 설정하는 데 걸리는 시간을 제한했다.
    • `연결 풀 타임아웃 (connection pool timeout)` : 클라이언트가 연결 풀에서 연결 객체를 얻는 데 걸리는 시간에 대한 제한이다. spring.datasource.hikari.connection-timeout 속성으로 설정했다.
    • `읽기 타임아웃 (read timeout)` : 초기 연결을 설정한 후 원격 리소스로부터 읽을 수 있는 시간의 제한이다. BookClient 클래스가 카탈로그 서비스를 호출할 때 읽기 제한 시간을 정의할 것이다.
  • 원격 서비스로부터 응답이 제한 시간 내에 수신되면 요청을 성공한다. 하지만, 타임아웃이 만료될 때까지 응답이 수신되지 않는 경우 폴백이 있다면 폴백을 수행한다. 그렇지 않으면 예외가 발생한다.

 

 

(1) 웹 클라이언트에 대한 타임아웃 정의

  • 프로젝트 리액터는 작동을 완료하기 위한 타임아웃을 정의하는 `timeout()` 연산자를 제공한다.
  • 이 연산자를 WebClient 호출 결과와 연결해 리액티브 스트림을 계속해나갈 수 있다.
  • BookClient 클래스의 `getBookByIsbn()` 메서드 → 3초 타임아웃 정의 추가
@Component
public class BookClient {
    ...
    
    public Mono<Book> getBookByIsbn(String isbn) {
        return webClient
                .get()
                .uri(BOOKS_ROOT_API + isbn)
                .retrieve()
                .bodyToMono(Book.class)
                .timeout(Duration.ofSeconds(3)); // GET 요청에 대해 3초의 타임아웃 설정
    }
}
  • 타임아웃이 초과하면 예외를 발생하는 대신 폴백을 제공해 무언가 다른 대체 작동을 수행할 수 있다.
    • 도서의 주문 가능 여부가 확인되지 않는 경우 주문을 접수할 수 없는 점을 고려해 주문이 거부되도록 빈 결과를 반환하는 것을 생각할 수 있다.
  • `Mono.empty()` 를 사용해 빈 결과를 반환할 수 있다.

 

 

(2) 타임아웃을 효과적으로 사용하는 방법의 이해

  • 소프트웨어 SLA를 충족하고 좋은 사용자 경험을 보장하려면 시스템의 모든 통합 지점에 대해 타임아웃 전략을 신중하게 설계해야 한다.
  • 읽기/쿼리 작업은 멱등적 즉, 여러 번 수행해도 값의 변경을 초래하지 않기 때문에 여러 번 수행된다고 해도 크게 문제되지 않는다.
  • 하지만, 쓰기/명령 작업의 경우, 시간이 초과하면 사용자에게 작업 결과를 올바르게 제공하는 것을 포함해 이 상황을 적절하게 처리해야 한다.
  • 카탈로그 서비스에 과부하가 걸리면 풀에서 JDBC 연결을 얻고 데이터베이스에서 데이터를 가져와 주문 서비스로 응답을 보내기까지 몇 초가 걸릴 수 있다. 이런 경우에는 폴백을 수행하거나 예외를 발생하기 보다는 요청 재시도를 고려해볼 수 있다.

 

 

[2] 재시도

  • 어떤 서비스에게 무언가 요청했으나 특정 시간 제한 내에 응답이 없거나 그 순간 요청을 처리하지 못하고 서버 오류라는 응답을 받으면 클라이언트가 요청을 다시 보내도록 설정할 수 있다.
  • 지수 백오프 전략을 사용해 재시도 횟수가 늘어남에 따라 지연 시간도 늘리는 것이다. 재시도가 늘어날수록 점점 더 많은 시간을 기다리는 것은 지원 서비스가 회복되고 다시 응답할 수 있는 시간을 충분히 주기 위함이다.

 

 

(1) 웹 클라이언트에 대한 재시도 정의

  • 프로젝트 리액터는 작동이 실패하면 재시도할 수 있는 `retryWhen()` 연산자를 제공한다.
  • 리액티브 스트림에서 이 연산자를 적용할 때 위치가 중요하다.
    • timeout() 뒤에 retryWhen() 연산자가 오면 재시도에 대해 타임아웃이 적용된다는 것을 의미한다.
    • retryWhen() 뒤에 timeout() 연산자가 오면 타임아웃이 전체 작동에 적용된다는 것을 의미한다.
  • BookClient에서 각 재시도에 대해 타임아웃이 적용되기를 원하기 때문에 첫 번째 옵션을 사용한다.
  • 타임아웃이 먼저 적용되고 제한 시간이 만료되면 retryWhen() 연산자가 적용되고 요청을 재시도한다.
  • BookClient 클래스의 `getBookByIsbn()` 메서드 업데이트
@Component
public class BookClient {
    ...

    public Mono<Book> getBookByIsbn(String isbn) {
        return webClient
                .get()
                .uri(BOOKS_ROOT_API + isbn)
                .retrieve()
                .bodyToMono(Book.class)
                .timeout(Duration.ofSeconds(3), Mono.empty())
                // 지수 백오프를 재시도 전략을 사용한다. 100밀리초의 초기 백오프로 총 3회까지 시도한다.
                .retryWhen(Retry.backoff(3, Duration.ofMillis(100)));
    }
}

 

 

(2) 재시도의 효과적 사용

  • 재시도는 원격 서비스가 순간적으로 과부하에 걸리거나 응답이 없을 때 응답을 받을 확률을 높인다.
  • 신중해야 한다. → 읽기와 쓰기시 타임아웃 설정을 다르게 해야 한다.
  • 읽기 작업과 같은 멱등적 요청을 재시도할 수 있다.
  • 전체 워크플로에 사용자가 참여하고 있다면 복원력과 사용자 경험 사이에서 균형을 잘 잡아야 한다. 요청 재시도로 인해 사용자를 너무 많이 기다리게 해서는 안 된다. 재시도가 반드시 필요하다면 사용자에게 알리고 요청 상태에 대한 피드백을 제공해야 한다.
  • 서비스가 완전히 다운되거나 404 오류를 반환하는 경우와 같이 계속 되풀이되는 오류때문에 서비스가 실패하는 것이라면 요청을 재시도해서는 안 된다.

 

 

[3] 폴백 및 오류 처리

  • 복원력이 높은 시스템 : 장애가 일어나더라도 상요자가 이를 인식하지 못하게 하면서 서비스를 계속 제공할 수 있는 시스템
  • 이번에 정의한 재시도 전략을 제한이 없었다. 404와 같은 허용 가능한 응답까지 포함해 오류 응답을 받는 한 요청을 재시도한다. → 하지만 404 응답을 받은 경우에는 재시도를 하지 않아야 한다.
  • 프로젝트 리액터는 특정 오류가 발생할 때 폴백을 정의하기 위한 `onErrorResume()` 연산자를 제공한다.
  • 이 연산자를 리액티브 스트림에서 `timeout()`과 `retryWhen()` 사이에 추가해 404 응답을 받는 경우 재시도 연산자가 수행되지 않도록 할 수 있다.
@Component
public class BookClient {
    ...

    public Mono<Book> getBookByIsbn(String isbn) {
        return webClient
                .get()
                .uri(BOOKS_ROOT_API + isbn)
                .retrieve()
                .bodyToMono(Book.class) 
                // 404 응답을 받으면 빈 객체를 반환한다.
                .timeout(Duration.ofSeconds(3), Mono.empty())
                .onErrorResume(WebClientResponseException.NotFound.class, exception -> Mono.empty())
                .retryWhen(Retry.backoff(3, Duration.ofMillis(100)))
                // 3회의 재시도 동안 오류가 발생하면 예외를 포착하고 빈 객체를 반환한다.
                .onErrorResume(Exception.class, exception -> Mono.empty());
    }
}

 


 

5.  스프링, 리액터, 테스트컨테이너를 이용한 리액티브 애플리케이션의 테스트

  • 애플리케이션이 다른 서비스에 의존하는 경우 그 서비스의 API 사양에 대해 테스트해야 한다.
  • 모의 웹 서버를 돌려 BookClient를 테스트한다.
  • `build.gradle` 파일 → 의존성 추가
dependencies {
    ...
    
    testImplementation 'com.squareup.okhttp3:mockwebserver'
}

 

 

[1] 모의 웹 서버로 REST 클라이언트 테스트

  • `OkHttp 프로젝트`는 HTTP 기반 요청/응답 상호작용의 테스트에 사용할 수 있는 모의 웹 서버를 제공한다.
  • `StepVerifier 객체`를 사용하면 리액티브 스트림을 처리하고 플루언트 API를 통해 단언을 단계별로 실행해 각각의 작동을 테스트할 수 있다.
  • 먼저 `BookClientTests 라는 새로운 테스트 클래스`를 만든다. → 모의 웹 서버를 설정한 다음 WebClient가 이 서버를 사용하도록 설정해야 한다.
package com.polarbookshop.orderservice.book;

import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.web.reactive.function.client.WebClient;

import java.io.IOException;

@TestMethodOrder(MethodOrderer.Random.class)
class BookClientTests {

    private MockWebServer mockWebServer;
    private BookClient bookClient;

    @BeforeEach
    void setup() throws IOException {
        this.mockWebServer = new MockWebServer();
        this.mockWebServer.start(); // 테스트 케이스를 실행하기 앞서 모의 서버를 시작한다.

        var webClient = WebClient.builder() // 모의 서버의 URL을 웹 클라이언트의 베이스 URL로 사용한다.
                .baseUrl(mockWebServer.url("/").uri().toString())
                .build();
        this.bookClient = new BookClient(webClient);
    }

    @AfterEach
    void clean() throws IOException {
        this.mockWebServer.shutdown(); // 테스트 케이스가 끝나면 모의 서버를 중지한다.
    }
}

 

  • `BookClientTests 클래스` → 주문 서비스의 클라이언트 기능을 확인하기 위한 테스트 케이스 정의
package com.polarbookshop.orderservice.book;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.io.IOException;

@TestMethodOrder(MethodOrderer.Random.class)
class BookClientTests {

    private MockWebServer mockWebServer;
    private BookClient bookClient;
    
    ...

    @Test
    void whenBookExistsThenReturnBook() {
        var bookIsbn = "1234567890";

        var mockResponse = new MockResponse() // 모의 서버에 의해 반환되는 응답을 정의한다.
                .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .setBody("""
                        	{
                        		"isbn": %s,
                        		"title": "Title",
                        		"author": "Author",
                        		"price": 9.90,
                        		"publisher": "Polarsophia"
                        	}
                        """.formatted(bookIsbn));

        mockWebServer.enqueue(mockResponse); // 모의 서버가 처리하는 큐에 모의 응답을 추가한다.

        Mono<Book> book = bookClient.getBookByIsbn(bookIsbn);

        StepVerifier.create(book) // BookClient가 반환하는 객체로 StepVerifier 객체를 초기화한다.
                .expectNextMatches(b -> b.isbn().equals(bookIsbn)) // 반환된 책의 ISBN이 요청한 ISBN과 일치하는지 확인한다.
                .verifyComplete(); // 리액티브 스트림이 성공적으로 완료됐는지 확인한다.
    }
}

 

  • 테스트 실행
$ ./gradlew test --tests BookClientTests

 

 

[2] @DataR3DBCTest 및 테스트컨테이너를 이용한 데이터 지속성 테스트

  • 스프링 부트를 사용하면 특정 애플리케이션 슬라이스만을 위한 구성 요소를 로드해 통합 테스트를 실행할 수 있다.
  • REST API의 경우 웹 플럭스 슬라이스에 대한 테스트를 작성해볼 것이다.
  • `@DataR2dbcTest 애너테이션`을 사용해 R2DBC 슬라이스에 대한 테스트를 작성한다.
    • `StepVerifier 유틸리티`를 사용해 OrderRepository의 리액티브 작동을 테스트한다.
    • PostgreSQL 테스트 컨테이너 인스턴스를 명시적으로 정의한다.
  • 카탈로그 서비스 애플리케이션의 경우 테스트 컨테이너의 자동 설정을 활용했다.
  • 지금은 테스트 컨테이너 클래스 내에서 테스트 컨테이너를 정의하고 `@Container`로 표시한다. → 클래스에 `@Testcontainers 애너테이션`을 표시하면 테스트컨테이너의 자동 시작과 동시에 중지가 활성화된다. → 스프링 부트의 `@DynamicProperties 애너테이션`을 사용해 테스트 데이터베이스의 크리덴셜과 URL을 애플리케이션에 전달한다.
package com.polarbookshop.orderservice.order.domain;

import com.polarbookshop.orderservice.config.DataConfig;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import reactor.test.StepVerifier;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@DataR2dbcTest // R2DBC 컴포넌트에 집중하는 테스트 클래스임을 나타낸다.
@Import(DataConfig.class) // 검사를 활성화하기 위한 R2DBC 설정을 임포트 한다.
@Testcontainers // 테스트컨테이너의 자동 시작과 중지를 활성화한다.
class OrderRepositoryR2dbcTests {

    @Container // 테스트를 위한 PostgreSQL 컨테이너를 식별한다.
    static PostgreSQLContainer<?> postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.12"));

    @Autowired
    private OrderRepository orderRepository;

    @DynamicPropertySource // 테스트 PostgreSQL 인스턴스에 연결하도록 R2DBC와 플라이웨이 설정을 변경한다.
    static void postgresqlProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.r2dbc.url", OrderRepositoryR2dbcTests::r2dbcUrl);
        registry.add("spring.r2dbc.username", postgresql::getUsername);
        registry.add("spring.r2dbc.password", postgresql::getPassword);
        registry.add("spring.flyway.url", postgresql::getJdbcUrl);
    }
		
    // 테스트 컨테이너가 JDBC와는 다르게 R2DBC에 대해서는 연결 문자열을 제공하지 않기 때문에 연결 문자열을 생성한다.
    private static String r2dbcUrl() { 
        return String.format("r2dbc:postgresql://%s:%s/%s", postgresql.getHost(),
                postgresql.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT), postgresql.getDatabaseName());
    }

    @Test
    void findOrderByIdWhenNotExisting() {
        StepVerifier.create(orderRepository.findById(394L))
                .expectNextCount(0)
                .verifyComplete();
    }

    @Test
    void createRejectedOrder() {
        var rejectedOrder = OrderService.buildRejectedOrder( "1234567890", 3);
        StepVerifier.create(orderRepository.save(rejectedOrder)) // StepVerifier 객체를 OrderRepository가 반환하는 객체로 초기화한다.
                // 반환된 주문이 올바른 상태를 가지고 있는지 확인한다.
                .expectNextMatches(order -> order.status().equals(OrderStatus.REJECTED)) // 리액티브 스트림이 성공적으로 완료됐는지 확인한다.
                .verifyComplete();
    }

}

 

  • 슬라이스 테스트는 테스트컨테이너에 기반하므로 도커 엔진이 로컬 환경에서 실행 중이어야 한다.
  • 테스트 실행
$ ./gradlew test --tests OrderRepositoryR2dbcTests

 

 

[3] @WebFluxTest를 이용한 REST 컨트롤러 테스트

  • 웹플럭스 슬라이스는 MVC 계층을 테스트하고 통합 테스트에 사용된 것과 비슷하게 `WebTestClient 유틸리티`를 사용해 테스트할 수 있다.
  • WebTestClient는 WebClient 객체의 향상된 버전이며 테스트를 간단하게 할 수 있도록 추가 기능이 포함되어 있다.
  • `OrderControllerWebFluxTests 클래스` 생성 → `@WebFluxTest(OrderController.class) 애너테이션`으로 표시
package com.polarbookshop.orderservice.order.web;

import com.polarbookshop.orderservice.order.domain.Order;
import com.polarbookshop.orderservice.order.domain.OrderService;
import com.polarbookshop.orderservice.order.domain.OrderStatus;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

// OrderController를 대상으로 한 스프링 웹플럭스 컴포넌트에 집중하는 테스트 클래스임을 나타낸다.
@WebFluxTest(OrderController.class)
class OrderControllerWebFluxTests {

    @Autowired
    // 웹 클라이언트의 변형으로 RESTful 서비스 테스트를 쉽게 하기 위한 기능을 추가로 가지고 있다.
    private WebTestClient webClient; 

    @MockBean // OrderService의 모의 객체를 스프링 애플리케이션 콘텍스트에 추가한다.
    private OrderService orderService;

    @Test
    void whenBookNotAvailableThenRejectOrder() {
        var orderRequest = new OrderRequest("1234567890", 3);
        var expectedOrder = OrderService.buildRejectedOrder(orderRequest.isbn(), orderRequest.quantity());
        given(orderService.submitOrder(orderRequest.isbn(), orderRequest.quantity()))
                .willReturn(Mono.just(expectedOrder)); // OrderService 모의 빈이 어떻게 작동해야 하는지 지정한다.

        webClient
                .post()
                .uri("/orders")
                .bodyValue(orderRequest)
                .exchange()
                .expectStatus().is2xxSuccessful() // 주문이 성공적으로 생성될 것을 예상한다.
                .expectBody(Order.class).value(actualOrder -> {
                    assertThat(actualOrder).isNotNull();
                    assertThat(actualOrder.status()).isEqualTo(OrderStatus.REJECTED);
                });

    }

}

 

  • 테스트 실행
$ ./gradlew test --tests OrderControllerWebFluxTests
반응형

'Programming' 카테고리의 다른 글

[클라우드 네이티브 스프링 인 액션] 3-3. 이벤트 중심 애플리케이션과 함수  (4) 2025.07.22
[클라우드 네이티브 스프링 인 액션] 3-2. API 게이트웨이와 서킷 브레이커  (6) 2025.07.10
[클라우드 네이티브 스프링 인 액션] 2-5. 스프링 부트를 위한 쿠버네티스 기초  (2) 2025.07.02
[클라우드 네이티브 스프링 인 액션] 2-4. 스프링 부트 컨테이너화  (8) 2025.06.27
[클라우드 네이티브 스프링 인 액션] 2-3. 클라우드에서 데이터 저장과 관리  (4) 2025.06.25
'Programming' 카테고리의 다른 글
  • [클라우드 네이티브 스프링 인 액션] 3-3. 이벤트 중심 애플리케이션과 함수
  • [클라우드 네이티브 스프링 인 액션] 3-2. API 게이트웨이와 서킷 브레이커
  • [클라우드 네이티브 스프링 인 액션] 2-5. 스프링 부트를 위한 쿠버네티스 기초
  • [클라우드 네이티브 스프링 인 액션] 2-4. 스프링 부트 컨테이너화
ssu_dev
ssu_dev
  • ssu_dev
    ssu
    ssu_dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (98)
      • Cloud (10)
      • HCI (2)
      • Algorithm (54)
      • Programming (13)
      • Computer Science (5)
      • System (6)
      • Trouble Shooting (6)
      • Work (1)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 인기 글

  • 태그

    node scaling
    K8s
    BOJ
    Pod Scheduling
    플로이드 워셜
    cs
    priorityqueue
    sort
    EKS
    Stack
    투포인터
    OS
    dfs
    bfs
    자료구조
    docker
    구현
    Deque
    Java
    Karpenter
  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
ssu_dev
[클라우드 네이티브 스프링 인 액션] 3-1. 리액티브 스프링: 복원력과 확장성
상단으로

티스토리툴바