Scala의 Future에 대한 이야기 (Scala Future Tutorial)

Posted by LA Stranger
2014. 8. 29. 03:55 Programming/Scala
 
 

Scala에서 동시성을 해결하기 위해서 두가지 접근을 하고 있는데 그 중 하나가 Future이고 다른 하나는 Actor이다. 이 글에서는 Future에 대한 내용을 설명을 하도록 하겠다.

Scala 버전 2.9.3 이상에서 아래 예제들을 실행해보길 권한다. Future는 Scala 2.10.0부터 도입되었지만 2.9.3 버전에도 백포트되어 추가되었다.

왜 순차적 코드는 나쁜가?

예를 들어 라면을 끓이는 경우를 생각해보자. 아래의 단계로 라면을 완성할 수 있다.

  1. 마음에 드는 라면을 고른다.
  2. 물을 끓인다.
  3. 물이 끓으면 라면을 넣는다.
  4. 파를 송송 짜른다.
  5. 라면에 파를 넣어 맛있는 라면 완성.

이 순서를 스칼라 코드로 작성해보면

import scala.util.Try
type Ramen = String
type SelectedRamen = String
case calss Water(temperature: Int)  
type GreenOnion = String
type ChoppedGreenOnion = String
type CookedRamen = String
type DeliciousRamen = String

// 각 단계에 대한 간단한 구현
def select(ramen: Ramen): SeletedRamen = s"Selected $ramen"
def heatWater(water: Water): Water = water.copy(temperature = 100)
def chop(greenOnion: GreenOnion): ChoopedGreenOnion = s"chopped $greenOnion"
def cook(ramen: SelectedRamen, heatedWater: Water): CookedRamen = "cookedRamen"
def combine(boiledRamen: BoiledRamen, choppedRamen: ChoppedGreenOnion): DeliciousRamen = "delicious ramen"

// 각 단계에서 오류가 발생할 경우를 위한 예외 클래스
case class SelectingException(msg: String) extends Exception(msg)
case class ChoppingException(msg: String) extends Exception(msg)
case class WaterBoilingException(msg: String) extends Exception(msg)
case class CookingException(msg: String) extends Exception(msg)

def prepareRamen(): Try[DeliciousRamen] = for {
    selectedRamen <- Try(select("JinRamen"))
    hotWater <- Try(heatWater(Water(10)))
    cookedRamen <- Try(cook(selectedRamen, hotWater))
    choppedGreenOnion <- chop("FreshGreenOnion")
} yield combine(cookedRamen, choppedGreenOnion)

이렇게 순서대로 표현을 해보면 아주 알기쉬운 단계로 맛있는 라면끓이기가 완성이 됨을 볼 수 있다. 그리고 각 단계를 순서대로 따라가면 되기 때문에 혼동이 될일이 전혀 없다.

그러나 이 단계를 밟아서 라면을 끓이고 먹는다고 생각해보자. 아주 숙련된 칼솜씨를 가진 사람이 아니고서야 파를 자르는 동안 라면이 불어서 라면의 참맛을 느낄 수 없다. 다된 라면을 먹기 전에 파를 자르는 시간 동안 라면이 할 일은 그냥 기다리는 것이다.

이렇게 하는 것은 명백히 귀중한 시간이란 자원을 낭비하는 것이다. 아마도 끓는 물에 라면을 넣고 끓여 지는 동안 혹은 뜨거운 물이 준비 되는 동안 파를 미리 잘라 놓을 수 있을 것 같다. 그리고 파 자르기가 끝난 후 기다렸다가 라면이 다 끓는 순간 파를 집어 넣고 바로 뜨거운 라면을 호호 불어가며 맛있게 먹을 수 있지 않을까?

프로그램을 만드는것도 이와 마찬가지로 만들어 질 수 있다. 예를 들어 웹서버는 수많은 스레드들이 리퀘스트를 처리하고 그에 따른 응답을 전송하며 동작한다. 만약 어떤 리퀘스트가 디비에 연결하고 디비에서 데이터를 가져와 다음을 처리해야 한다면 디비 작업을 처리하는 동안 해당 스레드는 블러킹이 되어 다른 리퀘스트를 처리할 수 없는 상태가 되는데 아마도 이런 상황을 맞이하고 싶지는 않을 것이다. 대신 비동기 리퀘스트 처리와 넌블로킹 IO를 통해 이러한 디비 연결이 발생할때 결과를 기다리는 동안 해당 스레드는 다른 리퀘스트의 작업을 처리할 수 있다.

니가 콜백을 좋아한다길래 내 콜백을 니 콜백에 넣었어.

요즘 한창 뜨고 있는 Node.js의 시스템적인 구조는 콜백기반으로 모든 것이 동작한다. 그러다 보니 위와 같은 우스게 소리도 나오는데 콜백과 콜백이 서로 얽히고 섥혀서 돌아가다 보니 어느 순간에는 코드를 읽기도 힘들고 디버깅하기도 힘든 상황이 발생하는 경우가 있다.

스칼라의 Future도 콜백을 사용한다. 그러나 이것은 Node.js의 그것보다는 훨씬 나은 대안을 가지고 있다. 그래서 콜백을 사용해야만 하는 일은 흔치 않을 것이다.

난 Future가 쓸모 없다는 것을 알고 있어.

아마 당신은 Java에서 구현된 Future에 대해 잘 알고 있을지도 모른다. Java에서의 Future구현으로 할수 있는 것은 단지 어떤 작업이 끝났는지 아니면 코드 진행을 블럭하면서 기다리는 것 밖에 없다. 이런 구현은 쓸모 없을 뿐만 아니라 다시 사용하고 싶지도 않을 것이다.

Scala의 Future구현은 그것과는 완전히 다르니 안심하라.

Future의 구현

scala.concurrent 패키지 내에 있는 Scala의 Future[T] 컨테이너 타입으로 어떤 연산의 결과가 결국 타입 T로 이루어질 것을 반영하는 타입이다. 그러나 연산이 항상 성공하리라는 보장은 없고 완전히 잘 못 되거나 어떤 경우는 타임아웃이 일어 날 수 있다. 따라서 future가 완료되었을 때 그 것은 성공적인 결과를 가지고 있거나 예외를 가지고 있을 수 있다.

Future는 한번만 쓰기 가능한 변경불가능한 컨테이너이다. 그리고 Future는 계산 결과값의 읽기 가능 인터페이스만을 제공한다. 계산된 결과에 쓰기를 원한다면 Promise라는 또다른 형태의 것으로 원하는 바를 이룰 수 있다. 따라서 API를 디자인 할때 명백히 두가지를 분리하여 디자인 해 놓았다고 볼 수 있다. 이 글에서 우리는 Future에 대한 내용만을 다루기로 한다.

Future 사용하기

위에서 예로 들은 라면 끓이기 예제를 Future를 사용한 형태로 바꾸어 보려고 한다. 먼저 우리는 동시에 처리가 가능한 것들을 처리 순서를 블로킹하지 않도록 Future를 리턴하도록 다시 써야 한다.

import scala.concurrent.future
import scala.concurrent.Future
import scala.concurrent.ExcutionContext.Implicits.global
import scala.concurrent.duration._
import scala.util.Random

def select(ramen: Ramen): Future[SelectedRamen] = Future {
    println("Start selecting ramen...")
    Thread.sleep(Random.nextInt(2000))
    if (ramen == "NongshimRamen") throw SelectingException("What????")
    println("Finished selecting ramen...")
    s"Selected $ramen"
}

def heatWater(water: Water): Future[Water] = Future[Water] {
    println("Heating the water now")
    Thread.sleep(Random.nextInt(2000))
    println("It's hot now!")
    water.copy(temparature = 100)
}

def chop(greenOnion: GreenOnion): Future[ChoppedGreenOnion] = Future {
    println("A chopping machine is chopping green onion")
    Thread.sleep(Random.nextInt(2000))
    println("The chopping machine has chopped green oninon!")
    s"chopped $greenOnion"
}

def cook(ramen: SelectedRamen, heatedWater: Water): Future[CookedRamen] = {
    println("Happy cooking!")
    Thread.sleep(Random.nextInt(2000))
    println("Ramen cooked!")
    s"cookedRamen"
}

여기서 설명이 필요한 몇가기가 있는데 먼저 Future의 컴패니언 오브젝트에는 apply 메소드가 존재하고 그 것은 두개의 파라메터를 전달 받는다.

object Future {
    def apply[T](body: => T)(implicit execctx: ExcutionContext): Future[T]
}

비동기적으로 실행되어야 할 연산은 body라는 이름의 첫번째 파라메터로 넘겨진다. 두번째 파라메터는 implicit형인데 이것의 의미는 스코프내에 타입과 일치하는 값이 있다면 명시적으로 전달하지 않아도 된다는 의미이다. 따라서 위의 예제에서는 import 로 선언한 global excution context가 자동으로 넘겨지게 된다.

ExcutionContext라는 것은 future의 내용을 실행하는 어떤 것인데 간단하게는 스레드 풀이라고 생각하면 쉽다. ExcutionContext는 암시적으로(implicitly)존재하므로 우리가 넘겨야 하는 것은 하나 남은 body에 해당하는 함수인자다.

위의 라면끓이기 예제에서 실제로 어떤 연산이 일어나지 않는다 그래서 데모를 목적으로 스레드를 랜덤한 시간동안 잠들도록 하였다. 그리고 "연산"이 일어나는 전 후에 프린트 문을 넣어서 비결정적, 동시적 코드 수행의 결과를 확인할 수 있도록 하였다.

Future로 리턴될 값은 Future 인스턴스가 생성된 후 ExcutionContext에 의해 할당된 특정 스레드에서 비 결정적인 타이밍에 계산되고 리턴된다.

콜백

때때로 콜백을 이용해서 모든 것이 단순해질 수 있다면 그것도 나쁘지는 않다. future에서 사용되는 콜백은 partial function이며 onSuccess 메소드에 콜백을 전달할 수 있다. onSuccess메소드로 전달된 콜백은 Future가 성공적으로 완료되었을 때 단 한번 호출이 된다. 그리고 그에 따라 계산된 값을 인풋으로 콜백이 받게 된다.

select("JinRamen").onSuccess { case selectedRamen =>
    println("Good, I love that ramen")
}

마찬가지 방법은 onFailure 메소드를 통해 실패한 경우에 대한 콜백 함수를 등록할 수 도 있고, Future가 완전히 성공하지 못했을 경우 해당 콜백은 Throwable 형의 인자를 전달 받게 된다.

일반적으로 이 두가지의 경우를 모두 처리할 수 있는 방법으로 콜백을 등록하는 것이 더 나은 방법이다.

import scala.util.{Success, Failure}
select("NongshimRamen").onComplete {
    case Success(selectedRamen) => println(s"Selected $selectedRamen")
    case Failure(ex) => println("I don't like that ramen!")
}

위의 경우 "NongshimRamen"을 인자로 전달하였으므로 FutureFailure로 끝나게 된다.

Future를 조합하기

만약 콜백이 콜백을 부르고 다시 그 콜백이 콜백을 부르는 상황에 이르게 된다면 코드를 해석하기가 매우 까다롭게 될 것이다. 그러나 스칼라에서 이렇게 하지 않아도 된다. 여러가지 Future들을 서로 엮는 다양한 방법을 제공하기 때문이다.

스칼라에서 모든 컨테이너 타입은 map 혹은 flapMap 연산이 가능하다. 그리고 Future도 컨테이너 타입이므로 이런 연산이 가능하다.

그런데 아직 결과가 나오지 않은 값에 대해 이러한 연산을 수행한다는 것이 어떤 의미인것인지?

미래를 매핑하기

놀랍게도 스칼라 프로그래머는 미래를 매핑할 수 있다. 라면 끓이기 예제를 가지고 한가지 더 예를 들자면 물이 끊었을 때 물 온도가 적당한지를 알고 싶다고 가정해보자. Future[Water]Future[Boolean]으로 매핑하여 그것이 가능하다.

val temperatureOkay: Future[Boolean] = heatWater(Water(25)).map {
    println("We're in the future!")
    (80 to 85).contains(water.temperature)
}

Future[Boolean] 값은 temparatureOaky에 할당이 되고 결국 제대로 계산된 bollean 값이 담기게 될것이다. 그러나 만일 heatWater가 Exception을 리턴하게 된다면 "We're in the future!" 는 절대 출력되지 않을 것이다.

map함수에 넘겨줄 함수를 작성 중일때 프로그래머는 이미 미래에 있는 것이다. 아니 정확히 말해서 가능한 한가지 미래에 있다고 볼 수 있다. 매핑 함수는 Future[Water] 인스턴스가 성공적으로 실행이 되자 마자 실행이 된다. 그러나 그 일이 일어날 시대에 당신이 존재할지 없을지는 모르는 일이다. 만일 Future[Water] 인스턴스가 실패할 경우 map 함수에 넘겨준 함수는 절대 실행되지 않고 대신에 Future[Boolean] 은 단순히 Failure 를 가지게 될 뿐이다.

미래를 평평하게 유지하기

만약 어떤 Future 의 결과가 다른 것의 결과에 의존성을 가지고 있다면 깊게 future끼리 복속된 구조를 피하기 위해 flatMap 을 사용하는 것이 좋을것이다.

예를 들어 물의 온도를 측정하는 과정이 시간이 좀 걸리고 그래서 온도가 적당한지에 대한 결정을 비동기적으로 하고 싶다면 이 문제를 어떻게 해결 할 수 있을까. 이미 우리는 Water의 인스턴스를 전달 받고 Future[Boolean] 을 반환하는 함수를 가지고 있다.

def temparatureOkay(water: Water): Future[Boolean] = Future {
    (80 to 85).contains(water.temperature)
}

Future[Future[Boolean]] 대신 Future[Boolean] 을 얻기 위해 map 대신 flatMap 을 사용하도록 하자.

val nestedFuture: Future[Future[Boolean]] = heatWater(Water(25)).map { 
    water => temperattureOkay(water)
}

val flatFuture: Future[Boolean] = heatWater(Water(25)).flatMap 
{
    water => temperatureOkay(water)
}

다시 한번 얘기하지만 전달된 함수는 Future[Water] 의 연산이 성공적으로 이루어지면 실행이 된다.

For comprehesions

flatMap을 호출하는 대신에 for comprehesion 코드로 작성항 수 도 있다. 실제로 하는 일은 똑같지만 가독성이 더 좋다. 위의 예는 다음과 같이 다시 쓸 수 있다.

val acceptable: Future[Boolean] for {
    heatedWater <- heatWater(Water(26))
    okay <- tempearatureOaky(heatedWater)
} yield okay

만약 병렬적으로 계산되어야 하는 다수의 계산 항목이 있다면 다음과 같이 쓸 수 있다.

def prepareRamenSequentially(): Future[DeliciousRamen] = {
    for {
        selectedRamen <- select("JinRamen")
        hotWater <- heatWater(Water(20))
        choppedGreenOnion <- chop("FreshGreenOnion")
        cookedRamen <- cook(selectedRamen, hotWater)
    } yield combine(cookedRamen, choppedGreenOnion)
}

보기 좋아 보인다 그러나 for comprehension이란 것이 중첩된 flatMap의 다른 표현일 뿐이기 때문에 heatWater에서 생성된 Future[Water]Future[SeletctedRamen] 이 완전히 성공으로 끝난 뒤에서야 인스턴스화 된다. 순차적으로 출력되는 로그를 통해 위의 구현이 어떻게 동작하는지 관찰할 수 있을 것이다.

따라서 독립적인 future들이 for comprehesion전에 인스턴스화 될 수 있게 하기 위해 아래와 같이 쓸 수 있다.

def prepareRamen(): Future[DeliciousRamen] = {
    val selectedRamen = select("JinRamen")
    val hotWater = heatWater(Water(20))
    val choppedGreenOnion = chop("FreshGreenOnion")
    for {
        selected <- selectedRamen
        water <- hotWater
        greenOnion <- choppedGreenOnion
        cookedRamen <- cook(selectedRamen, water)
    } yield combine(cookedRamen, greenOnion)
}

이제 for comprehesion이 시작되기 전에 세 futures 오브젝트는 생성되고 동시적으로 내용이 실행된다. 콘솔에 출력되는 내용을 보면 그 상황을 명백히 이해할 수 있을 것이다. 한가지 확실한 것은 "Happy Cooking!" 메세지가 가장 마지막에 나온다는 점이다. 왜냐하면 cook 함수는 다른 두개의 future들의 결과값을 필요로 하기 때문이다. 그리고 그것은 오로지 for comprehension 내에서 생성된다. 즉 나머지 future들이 완전히 성공적으로 끝났다는 의미이기도 하다.

실패 처리

지금까지 본 예제는 Future[T] 가 항상 성공할 것이라는 것에 기초로 하고 짜여져 있다. 따라서 map, flatMap, filter 같은 것들은 future들이 완전히 성공할 것이라는 가정아래 사용되고 있다. 만약 이중 어떤 것이 잘못되는 상황을 처리하고자 하면 failed 메소드를 Future[T] 인스턴스에 사용하여 Future[Throwable] 값을 실패하는 경우에 전달 받을 수 있다. 그리고 map 함수를 Future[Throwable] 에 매핑하도록 하여 Future[T]가 완전히 실패로 끝나는 상황의 처리를 매핑 함수를 통하여 처리하도록 만들 수 있다.

정리

Future 를 사용하여 비동기적, 동시적으로 수행되는 코드를 쉽게 만들 수 있음을 알아 보았다. 블록킹 코드를 아주 쉽게 future 함수로 래핑하여 쉽게 동시성을 가지는 코드로 만들 수 있지만 그러나 처음 부터 넌블록킹 코드로 만드는 것이 더 낫고 이를 위해 Promise 를 사용하는 방법은 다음에 다시 알아보도록 하자.