[Spring] Spring WebFlux 기초 1

donghyeok·2023년 6월 18일

비동기-논블로킹 프로그래밍

1. 비동기 프로그래밍

  • 동기 프로그래밍은 작업의 실행 흐름이 순차적으로 동작한다.
  • 순차적으로 동작하는 프로그램은 코드를 파악하기 쉽고 결과를 예측하기 쉬우므로 디버깅 쉬움
  • 특정 작업을 실행하는 동안에는 다른 작업을 할 수 없다는 단점 존재
  • 비동기 프로그래밍은 작업의 실행 흐름이 순차적이지 않다.
  • UI 애플리케이션의 경우 특정 이벤트가 발생할 경우에 반응하는 동작을 구현해야 하는데 이럴 때 필수적으로 비동기 프로그래밍을 사용하게 된다.

2. 비동기 프로그래밍 구현

Thread

  • JVM에서 가장 기본이 되는 비동기 처리 방식이다.
  • 멀티 스레드를 사용하면 스케줄링 알고리즘에 의해 스레드가 전환되면서 작업을 처리하는데 이를 컨텍스트 스위칭이라 한다.
  • 스레드가 많아져서 생기는 OOM을 피하기 위해서는 스레드풀을 사용해야 한다.
  • 직접 만드는 것보다 ExecutorService를 사용하면 쉽고 안전하게 스레드풀을 사용할 수 있다.
fun main() {
	val pool: ExecutorService = Excutors.newFixdThreadPool(5)
    try {
    	for (i in 0 <= .. <= 5) {   // 1,2,3,4,5,1 순서로 출력됨.
        	pool.execute {
            	println("current-thread-name : ${Thread.currentThread().name}")
            }
        }
    } finally {
    	pool.shutdown()
    }
    println("current-thread-name : ${Thread.currentThread().name}")
}

Future

  • 퓨처는 비동기 작업에 대한 결과를 얻고 싶은 경우에 사용된다.
  • 스레드는 Runnable을 사용해 비동기 처리를 하지만 퓨처를 사용해 결과를 얻기 위해선 Callable을 사용한다.
  • 퓨처를 사용하면 비동기 작업을 쉽게 구현할 수 있지만 몇가지 단점을 가진다.
  • 먼저 get함수는 비동기 작업의 처리가 완료될 때 까지 다음 코드로 넘어가지 않고 무한정 대기하거나 지정된 타임아웃 시간까지 블로킹된다.
  • 또한 퓨처를 사용하면 동시에 실행되는 한개 이상의 비동기 작업에 대한 결과를 하나로 조합하여 처리하거나 수동으로 완료처리를 할 수 있는 방법을 지원하지 않음.

CompletableFuture

  • Java8 이상부터는 이러한 퓨처의 단점을 극복하기 위해 결과를 받기까지 블로킹하지 않는 CompletableFuture 인터페이스를 지원한다.
fun main() {
	val completableFuture = CompletableFuture.supplyAsync {
    	Thread.sleep(2000)
        sum(100, 200)
    }
   	println("계산 시작")
    
    val result = completableFuture.thenApplyAsync(::println)
   	//이 이후의 코드들이 블로킹되지 않고 계속 수행된다.
    //Future.get()의 경우 블로킹되어 이후 코드들이 수행되지 않음.
    println(result)
    
    while(!completableFuture.isDone) {
    	Thread.sleep(500)
        println("계산 결과를 집계 중입니다.")
    }
}

옵저버 패턴

옵저버 패턴

  • 옵저버 패턴이란 디자인 패턴 중 하나로 관찰 대상이 되는 객체가 변경되면 대상 객체를 관찰하고 있는 옵저버에게 변경사항을 통지하는 디자인 패턴이다.
  • 옵저버 패턴을 사용하면 객체 간의 상호작용을 쉽게 하고 효과적으로 데이터를 전달 할 수 있다.
  • 옵저버 패턴은 관찰 대상인 서브젝트와 서브젝트를 관찰하는 옵저버로 이뤄져 있다.
  • 하나의 서브젝트에는 1개 또는 여러개의 옵저버를 등록할 수 있다.
  • 서브젝트의 상태가 변경되면 자신을 관찰하는 옵저버들에게 변경사항을 통지한다.
  • 서브젝트로 변경사항을 통지 받은 옵저버는 부가처리를 한다.
  • 요청하는 쪽에서 데이터를 일방적으로 받는 Push 방식이다.
class Coffee(val name: String) 

class Barista : Observable() {
	private lateinit var coffeeName: String
    
    fun orderCoffee(name: String) {
    	this.coffeeName = name
    }
    
    fun makeCoffee() {
    	setChanged() 
        notifyObservers(Coffee(this.coffeeName))
    }
}

class Customer(val name: String) : Observer {
	override fun update(o: Observable?, arg: Any?) {
		val coffee = arg as Coffee
        prntln("${name}${coffee.name}을 받았습니다.")
    }
}

fun main() {
	val barista = Barista()
   	barista.orderCoffee("아이스 아메리카노")
    
    val cust1 = Customer("고객1")
    val cust2 = Customer("고객2")
    
    barista.addObserver(cust1)
    barista.addObserver(cust2)
    
    barista.makeCoffee()
    //모든 고객(옵저버)에게 통지가 되고 고객의 update 메서드 실행 
}

옵저버 패턴의 장점

  • 옵저버 패턴은 고객이 바리스타에게 정보를 일정 간격으로 완성됐는지 확인하는 처리를 없애준다. (Pulling 제거)
  • 이러한 Pulling은 불필요한 호출이 많아 성능 문제가 있고 간격이 너무 길면 실시간성이 떨어질 수 있다.
  • 옵저버 패턴은 데이터를 소비하는 측에 통지하는 Push-based 방식이다.
  • 옵저버 패턴에서는 서브젝트와 옵저버가 관심사에 따라 역할과 책임이 분리되어 있다.

이터레이터 패턴

  • 이터레이터 패턴은 데이터의 집합에서 데이터를 순차적으로 꺼내기 위해 만들어진 디자인 패턴이다.
  • 데이터 집합이 얼만큼의 크기를 가진지 알 수 없는 경우 이터레이터 패턴을 사용하면 순차적으로 데이터를 꺼내올 수 있다.
  • 요청하는 쪽에서 데이터를 요청하는 Pull방식이다.
data class Car(val brand:String)

class CarIterable(val cars: List<Car> = ListOf()) : Iterable<Car> {
	
    override fun iterator(): Iterator<Car> = CarIterator(cars)
}

class CarIterator(val cars: List<Car> = ListOf(), car index : Int = 0) : Iterator<Car> {
	
    override fun hasNext(): Boolean {
    	return cars.size > index
    }
    
    override fun next(): Car {
    	return cars[index++]
    }
}

fun main() {
	val carIterable = CarIterable(ListOf(Car("람보르기니"), Car("페라리")))
    
    val iterator = carIterable.iterator()
    
    while (iterator.hesNext()) {
    	println("브랜드 : ${iterator.next()}")
    }
}	

리액티브 프로그래밍이란?

  • 리액티브 프로그래밍은 데이터 또는 이벤트의 변경이 발생하면 이에 반응해 처리하는 프로그래밍 기법이다.
  • 리액티브 프로그래밍은 비동기 프로그래밍을 처리하는 새로운 접근 방식
  • 리액티브 프로그래밍은 옵저버 패턴에 영감을 받아 설계되었고 데이터의 손쉬운 비동기 처리를 위해 함수형 언어의 접근 방식을 사용

1. 리액티브 이전의 프로그래밍

  • 리액티브 프로그래밍 이전에는 대부분 비동기 프로그래밍이 콜백 기반 방식을 사용했다.
  • 콜백헬 문제를 야기할 수 있다.
fetch("/api/me") { user -> 
	fetch("/api/${user.id}/followers") { followers -> 
    	fetch("/api/${user.id}/likes") { likes ->
        	//콜백 헬 
     	}	   
    }
}

2. 리액티브 프로그래밍 적용

  • 리액티브 프로그래밍을 사용하면 콜백 헬 문제를 함수형 프로그래밍 관점에서 해결 가능
fetchReactive("/api/me")
	.zip { user -> fetchReactive("/api/${user.id}/followers") 
    .zip { user -> fetchReactive("/api/${user.id}/likes")
    .flatMap { follwers, likes -> 
    	//로직 구현 
    }

리액티브 스트림

  • 리액티브 스트림은 리액티브 프로그램의 표준 API 사양을 말함
  • 비동기 데이터 스트림과 논-블로킹 백프레셔에 대한 사양을 제공
  • 리액티브 스트림 이전의 비동기 애플리케이션에서는 CPU의 멀티코어를 제대로 활용하기 위해 복잡한 병렬 처리 코드가 필요했다.
  • 기존의 방식은 처리할 데이터가 무한정 많아져서 시스템의 한계를 넘으면 병목 현상이나 에러를 발생시킨다.

1. 리액티브 스트림의 다양한 구현체

  • 리액티브 스트림은 TCK(Technology Compatibility Kit)을 지원하기 때문에 라이브러리가 정해진 사양에 맞게 구현되었는지 보장할 수 있다 (테스트 도구)
  • 리액티브 스트림은 TCK만 통과한다면 표준 사양에 포함되지 않은 라이브러리만의 추가 기능도 자유롭게 지원할 수 있게 함

다양한 구현체들

  • Project Reactor
  • RxJava
  • JDK9 Flow
  • Akka Streams
  • Vert.x

2. 리액티브 스트림 사양

리액티브 스트림 인터페이스의 역할

  • 리액티브 스트림 사양(Specification)은 핵심 인터페이스프로토콜로 구성된다.
  • 인터페이스별 역할
    - Publisher : 데이터를 생성하고 구독자에게 통지
    - Subscriber : 데이터를 구독하고 통지받은 데이터를 처리
    - Subscription : Publisher, Subscriber 간의 데이터를 교환하도록 연결하는 역할, 데이터의 개수를 설정하거나 구독 해지 가능
    - Processor : Publisher, Subscriber를 모두 상속받은 인터페이스
  • 구독자는 자신이 처리할 수 있는만큼의 데이터를 요청하고 처리한다.
  • 발행자가 제공할 수 있는 데이터 양은 무한하고 순차적 처리를 보장한다.
  • 서브스크립션은 발행자와 구독자를 연결하는 매개체이며 구독자가 데이터를 요청하거나 구독을 해지하는 등 데이터 조절에 관련된 역할
  • 프로세스는 발행자 구독자 기능을 모두 포함하며 데이터를 가공하는 중간 단계에서 사용

리액티브 스트림의 데이터

  • 리액티브 스트림은 발행자 구독자간의 데이터 전달에 사용되는 규칙을 프로토콜로 정의하고 있다.
  • 구독자는 4개의 추상 메서드를 프로토콜로 가지고 있다.

    onSubscribe : 구독시 최초에 한번만 호출
    onNext : 구독자가 요구하는 데이터의 수만큼 호출
    onError : 에러 또는 더이상 처리할 수 없는 경우
    onComplete : 모든 처리가 완료된 경우

리액티브 스트림 데이터 처리 프로토콜 흐름

  • 각 메서드 호출을 시그널이라 부르고 각 시그널은 호출되는 순서가 다르다
  • onSubscribe는 최초 구독에 대한 초기화를 담당한다.
  • onNext는 발행자로부터 통지받을 데이터가 있는 경우 구독자가 요청하는 만큼 계속 호출된다. 이때 발행자가 통지하는 데이터의 수는 구독자가 요구하는 수와 같거나 적어야 한다. 이런 사양은 발행자가 너무 많은 데이터를 통지할 경우 문제가 발생할 수 있기 때문이다.
  • 발행자 측에서 처리 중 에러가 발생하면 onError를 구독자에게 통지하고 더이상 데이터를 통지하지 않는다. 구독자는 onError 시그널을 받으면 이에 대한 에러 처리를 할 수 있다.
  • onComplete는 모든 데이터가 통지 완료되면 호출된다. 이후 어떤 시그널도 발생하면 안된다.

0개의 댓글