본문 바로가기

Spring

스프링 부트 웹 애플리케이션 만들기 with Spring Reactive Web

스프링 부트란 무엇인가

스프링 부트는 스프링 포트폴리오를 신속하게, 미리 정의된 방식으로, 이식성 있게, 실제 서비스 환경에 사용할 수 있도록 조립해 놓은 것이다.

  • 신속성: 의존관계를 포함한 여러 요소에 대한 의사결정을 신속히 적용할 수 있도록 해준다.
  • 미리 정의된 방식: 스프링 부트를 어떻게 사용할지 구성을 정하면 기본적인 설정값이 자동으로 지정된다.
  • 이식성: JDK가 있는 곳이라면 스프링 부트 애플리케이션은 어디에서나 실행할 수 있다.
  • 실제 서비스 환경에 사용 가능: 견고한 완성품이다.

리액티브 프로그래밍 소개

  • 대규모 사용자가 지속적으로 증가하는 시스템(하이엔드 시스템)은 비동기적으로 인입되는 거의 무제한의 데이터 스트림을 논블로킹 방식으로 처리할 수 있어야 한다.
  • 1970 년대에 리액티브 프로그래밍 기술이 나왔으나 이제서야 주목을 받는 이유가 무엇일까?
    • 리액티브 프로그래밍을 써야할 정도로 대규모 서비스가 많지 않았기 때문이다.
    • 서버를 더 투입하는 방식이 먹히지 않을 정도의 트래픽
    • 그렇기 때문에 개발자들은 기존의 자원을 더 효율적이고 일관성 있게 사용하는 방법을 찾으려 했다.
    • 그렇게 나온 해법이 리액티브 스트림이다.
  • 리액티브 스트림(Reactive Stream)
    • 발행자(Publisher)와 구독자(Subscriber)사이의 간단한 계약을 정의하는 명세
    • 구독자(Subscriber)가 수요 조절을 할 수 있는 배압(Backpressure)를 적용할 수 있다.
    • 프로젝트 리액터는 VMware에서 만든 리액티브 스트림 구현체다.
  • 프로젝트 리액터의 특성
    • 논블로킹, 비동기 프로그래밍 모델
    • 함수형 프로그래밍 스타일
    • 스레드를 신경 쓸 필요 없는 동시성

리액터 타입

  • 리액티브 스트림은 수요 조절에 기반하고 있다.
  • 프로젝트 리액터는 Flux<T>를 사용해서 이러한 수요 조절을 구현한다.
  • Flux<T>는 실제 물건을 전달해주는 역할을 하는 플레이스홀더로 서빙 점원과 비슷하다.
class KitchenService {
   Flux<Dish> getDishes() {
			// You could model a ChefService, but let's just
      // hard code some tasty dishes.
		returnFlux.just(//
			new Dish("Sesame chicken"),//
			new Dish("Lo mein noodles, plain"),//
			new Dish("Sweet & sour beef"));
	   }
}
  • 위의 예제 코드는 리액터를 활용한 간단한 KitchenService 코드이다.
  • 서빙 점원이 호출하게 될 getDishes 함수는 세 가지 요리를 하드코딩으로 반환하고 있다.
    • Flux<Dish>안에 포함된 요리는 아직 완성되지 않았고 머지 않아 완성될 것이다.
    • 요리가 완성되면 서빙 점원이 행동(act)할 수 있다. 하지만 리액터는 논블로킹 방식으로 동작하기 때문에 서빙 점원은(서버 스레드) 다른 일을 수행할 수 있다.

Flux vs Future

  • 결과가 아직 정해지지 않았고 미래 어느 시점이 되어야 알 수 있다는 점에서 둘은 비슷하다.
  • Future는 이미 시작되었음을 나타내는 반면 Flux는 시작할 수 있음을 나타낸다.
  • Future에는 없는 Flux의 특징
    • 하나 이상의 값(Dish) 포함 가능
    • 각 값(Dish)이 제공될 때 어떤 일이 발생하는지 지정 가능
    • 성공과 실패의 두 가지 경로 모두에 대한 처리 방향 정의 가능
    • 결과 폴링 불필요
    • 함수형 프로그래밍 지원
  • Future는 정확하게 하나의 값을 제공하는 것이 목적이었고, Flux는 다수의 값을 지원하는 것이 목적으로 만들어 졌다.

평범한 서빙점원

class SimpleServer {

		private final KitchenService kitchen;

		SimpleServer(KitchenService kitchen) {
			this.kitchen = kitchen;
		}

		Flux<Dish> doingMyJob() {
			return this.kitchen.getDishes() //
					.map(dish -> Dish.deliver(dish));
		}
	}
  • doingMyJob() 함수는 레스토랑 매니저가 서빙 점원에게 kitchen에 가서 요리를 받아오는 일을 수행한다고 볼 수 있다.
  • 주방에 요리를 요청한 후에는 deliver(dish)를 호출해서 요리를 손님에게 가져다주는 일을 지정했다.
  • 위의 예제 코드는 단순한 형태의 리액티브 컨슈머다. 리액티브 컨슈머는 다른 리액티브 서비스를 호출하고 결과를 변환(transform)한다.
  • map() 함수는 인자로 받은 함수를 Flux에 담겨 있는 각 요리에 적용해서 변환하고 Flux에 담아 반환하므로, 매핑 함수는 무언가를 반드시 반환해야 한다.
  • 프로젝트 리액터는 함수형 프로그래밍에서 수행하는 변환 뿐만 아니라, onNext(), onError(), onComplete 시그널 처럼 리액티브 스트림 수명주기에 연결 지을 수도 있다.

친절한 서빙점원

class PoliteServer {

		private final KitchenService kitchen;

		PoliteServer(KitchenService kitchen) {
			this.kitchen = kitchen;
		}

		Flux<Dish> doingMyJob() {
			return this.kitchen.getDishes() //
					.doOnNext(dish -> System.out.println("Thank you for " + dish + "!")) //
					.doOnError(error -> System.out.println("So sorry about " //
							+ error.getMessage())) //
					.doOnComplete(() -> System.out.println("Thanks for all your hard work!")) //
					.map(Dish::deliver);
		}
	}
  • onNext, onError, onComplete 메소드는 2번 이상 사용될 수도 있으므로 필요한 만큼 핸들러를 추가해주면 된다.
    • onNext(), onError(), onComplete()는 리액티브 스트림의 시그널이다.

구독 : 흐름의 시작

  • 프로젝트 리액터에서는 필요한 모든 흐름과 모든 핸들러를 정의할 수 있지만, 구독(subscription)하기 전까지는 실제로 아무런 연산도 일어나지 않는다.
class PoliteRestaurant {

		public static void main(String... args) {
			PoliteServer server = //
					new PoliteServer(new KitchenService());

			server.doingMyJob().subscribe( //
					dish -> System.out.println("Consuming " + dish), //
					throwable -> System.err.println(throwable));
		}
	}
  • 위의 코드에서 server.doingMyJob()을 호출한 후에 subscribe()를 호출한다.
    • doingMyJob()은 Flux<Dish>를 반환하지만 subscribe()를 호출하지 않으면 아무런 일도 일어나지 않는다.
    • subscribe의 첫 번째 인자는 Consumer를 받고있다. 이 콜백은 onNext() 시그널과 함께 완성된 모든 요리 각각에 대해 호출한다.
    • 두 번째 인자는 throwable → System.err.println(throwable) 이라는 람다식을 받고 있다. 이 콜백은 onError(throwable) 시그널을 받았을 때 어떻게 처리할지 표현한다.

지금까지의 예제를 정리해보자

  • 레스토랑 손님들 ⇒ 웹 사이트를 방문하는 사람들
  • 주방 ⇒ 다양한 데이터 저장소와 서버 쪽 서비스의 혼합물
  • 서빙 점원 ⇒ 웹 컨트롤러
  • 주문을 비동기적으로, 논블로킹 방식으로 처리하는 서빙 점원이 하는 일은 리액티브 웹 컨틀롤러가 하는 일과 동일하다.

스프링 웹플럭스의 등장

  • 잠재적으로 사용자의 트래픽이 많아질 것이라 예상되는 서비스라면 웹 계층을 확장하는 것이 필수다.
  • 확장 요구가 커질수록 스프링 웹플럭스를 활용해서 웹 요청을 리액티브하게 처리하는 것이 올바른 선택이다.
  • 스프링 MVC는 자바 서블릿 API를 기반으로 만들어졌다.
    • 서블릿 API는 기본적으로 블로킹 방식으로 동작한다.
    • 서블릿 3.1에 도입된 비동기 방식은 리액티브 이벤트 루프(event loop)와 배압 시그널을 지원하지 않는다.
  • 100% 논블로킹, 비동기 웹 컨테이너 Netty의 등장과 웹플럭스의 사용으로 MVC 모델 그대로 작성한 코드를 네티 위에서 실행할 수 있다.

스프링 부트로 이커머스 플랫폼 만들기

프로젝트 페어런트

  • spring-boot-starter-parent 로 프로젝트의 기준을 지정한다.
  • 스프링 스타터 페어런트를 적용하면 미리 정의된 여러 가지 속성 정보, 의존관계, 플러그인을 상속받게 된다.
    • 여기에는 전체 스프링 포트폴리오와 잭슨, 네티, 프로젝트 리액터 등 다양한 서드파티 라이브러리도 포함된다.
  • 다른 라이브러리가 필요하다면 빌드 파일에 추가하기만 하면 스프링 부트가 페어런트 정보를 바탕으로 적합한 버전을 찾아 사용할 수 있게 해준다.
  • 스프링 부트 새 버전이 출시되는 경우, 페어런트 버전만 갱신하면 그에 포함된 모든 라이브러리도 적합한 버전으로 자동으로 업그레이드 된다.

스프링 부트 스타터

  • 스타터는 모듈화돼 있고 애플리케이션이 필요로 하는 것을 정확히 집어올 수 있도록 설계되었다.

@SpringBootApplication

  • 이 애너테이션은 자동설정과 컴포넌트 탐색 기능을 포함하는 복합 애너테이션이다

자동설정

  • 자동설정이란 스프링 부트 애플리케이션의 설정 내용을 분석해서 발견되는 정보에 맞게 다양한 빈을 자동으로 활성화하는 조건 분기 로직이다.
  • 자동설정 정책
    • 클래스패스
    • 다양한 설정 파일
    • 특정 빈의 존재 여부
    • 기타…
  • 애플리케이션의 여러 측면을 살펴보고 유추한 다음, 다양한 컴포넌트를 자동으로 활성화한다.
  • 직접 지정한 설정이 없으면 스프링 부트가 알아서 필요한 빈을 생성하고 지정한 설정이 있으면 지정한 대로 동작한다.

컴포넌트 탐색

  • 스프링이 빈의 존재를 자동으로 찾아내는 기능
  • 스프링 애플리케이션이 실행되면 모든 빈은 애플리케이션 컨텍스트에 등록된다.

스프링 웹플럭스 컨트롤러 생성

@RestController
class ServerController(
    val kitchenService: KitchenService
) {
    @GetMapping("/server",
    produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun serveDishes() : Flux<Dish>{
        return kitchenService.getDishes()
    }

    @GetMapping("/served-dishes",
    produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun deliverDishes() : Flux<Dish>{
        return kitchenService.getDishes()
            .map { Dish.deliver(it) }
    }
}
  • 반환하는 데이터는 text/event-stream 이고, 클라이언트는 서버가 반환하는 스트림을 쉽게 소비(consume)할 수 있다.
@Service
class KitchenService {

    fun getDishes(): Flux<Dish> {
        return generate { sink: SynchronousSink<Dish> ->
            sink.next(randomDish())
        }.delayElements(Duration.ofMillis(250))
    }

    fun randomDish(): Dish {
        return menu.get(picker.nextInt(menu.size))
    }

    private val menu: List<Dish> = Arrays.asList(
        Dish("Sesame chicken"),
        Dish("Lo mein noodles, plain"),
        Dish("Sweet & sour beef")
    )
    private val picker: Random = Random()

}
  • Flux.generate() 메서드의 파라미터 타입은 Consumer<SynchronousSink<T>> 이다.
    • sink는 Flux의 핸들로서, Flux에 포함될 원소를 동적으로 발행할 수 있게 해준다.
    • curl -N -v localhost:8080/served-dishes
    • 위으 명령어로 데이터 스트림을 확인해볼 수 있다.

Reference

  • 스프링 부트 실전 활용 마스터, 그렉 턴키스트