스칼라.js의 Future 및 ExecutionContext 지원 Scala

자바스크립트에서 Promise를 이용한 비동기 프로그래밍은, 가독성을 떨어뜨리고 제어를 어렵게 만드는 중첩된 콜백 문의 좋은 대안이다. 스칼라에서는 Future/Promise를 통해 유사한 패턴을 구현한다. 만일 Akka를 사용해봤다면 Future를 쓰는 일은 숨 쉬듯 자연스러울 것이다. 스칼라.js(Scala.js) 프로젝트에서는 자바스크립트와 스칼라의 표준 라이브러리를 모두 사용할 수 있는데, 일반적으로 스칼라.js를 사용하는 이유가 강력한 타입 지원과 서버를 아우르는 언어적 일관성에 있다는 점을 고려하면 클라이언트에서 자바스크립트의 Promise를 퍼서드로 사용하는 것 보다는 스칼라의 Future를 사용하는 것이 더 낫다.

여기서는 스칼라.js가 어떻게 Future를 구현하는지 설명한다. 이를 위해 Future가 동작할 수 있도록 해주는 ExecutionContext에 대해 간략히 살펴보고, 이를 브라우저 환경에서 어떻게 구현했는지를 살펴본다. 마지막으로,  여기서 발생하는 한계와 그로 인한 테스트 제약을 스칼라테스트(Scalatest) 3.0의 새로운 기능을 통해 해결한다. 이 글에서 다루는 스칼라의 버전은 2.11.8, 스칼라.js의 버전은 0.6.10, 스칼라테스트는 3.0-RC4 이다.

ExecutionContext

Future는 비동기 코드를 실행하고 흐름을 제어할 수 있는 apply/andThen/map/onComplete 등의 메서드들을 제공한다. 이들 메서드를 통해 전달받은 코드 블록은 블러킹되지 않기 위해 별도의 컨텍스트에서 실행된다. 이때 사용되는 컨텍스트가 바로 ExecutionContext(이하 EC)이다. 이를 위해 앞서 열거한 메서드들은 EC를 암묵 인자로 요구하며, 이렇게 인자로 받은 EC를 통해 비동기 코드를 실행한다. EC의 정의는 사실 단순한 트레이트인데 그 내용을 보면 하는 일을 미루어 짐작할 수 있다.

trait ExecutionContext {

  // 주어진 코드 블록을 이 컨텍스트에서 실행한다.
  def execute(runnable: Runnable): Unit

  // 비동기 연산의 실패를 보고한다.
  def reportFailure(@deprecatedName('t) cause: Throwable): Unit

  // 작업의 실행을 준비한다.
  def prepare(): ExecutionContext = this
}

Future 사용시에는 위 트레이트의 구현체가 필요한데, 간단하게나마 다음과 같이 만들어보면 EC가 어떻게 동작하는지를 알 수 있다.

scala> implicit val ec = new ExecutionContextExecutor {
     |    def execute(runnable: Runnable) {
     |      println("executed!")
     |      try {
     |        runnable.run()
     |      } catch {
     |        case t: Throwable => reportFailure(t)
     |      }
     |    }
     |  
     |    def reportFailure(t: Throwable): Unit = println("failure reported!")
     |  }
ec: TestExecutionContext.type = TestExecutionContext$@62891fc8

scala> Future { println(1) } map { _ => println(2) } andThen { case _ => throw new Exception("3!") } foreach { _ => println(4) }
executed!
1
executed!
2
executed!
failure reported!
executed!
4

물론 스칼라 표준 라이브러리에는 기본으로 제공되는 구현체가 있다. 바로 ExecutionContext.Implicits.global 가 그것이다. 아마 이미 써 본 경험이 있을 것이다. 만일 EC를 암묵 인자로 제시하지 않고 Future를 사용하려고 하면 컴파일이 실패하는데, 이때 트레이트에 정의된 ImplicitNotFound 어노테이션에 의해 이 ExecutionContext.Implicits.global을 임포트하라는 사용하라는 안내 메시지가 나온다. 이 구현체는 정확히는 ExecutionContextImpl 이라는 싱글턴 객체이며, 기본적으로 JVM에서 가용 가능한 프로세서 수만큼의 쓰레드 풀을 운용해 코드를 실행한다. 아래 예시는 4개의 프로세서가 존재하는 환경에서, 주어진 10개의 작업이 4개 단위로 끊어서 실행되는 것을 보여준다.

scala> java.lang.Runtime.getRuntime.availableProcessors
res0: Int = 4

scala> for(i <- 1 to 10) {
     |   Future {
     |     Thread.sleep(5000) // 작업을 5초간 점유한다.
     |     println(i + "/" + (System.currentTimeMillis / 1000))
     |   }
     | }

1/1467777485
3/1467777485
4/1467777485
2/1467777485
5/1467777490
6/1467777490
7/1467777490
8/1467777490
9/1467777495
10/1467777495

스칼라.js에서의 ExecutionContext

이제껏 자바스크립트가 구동되는 브라우저들은 단일 쓰레드 환경이었기 때문에, HTML5에 이르러 WebWorker API 추가되기 전까지는 쓰레드와 유사한 개념이 없었다. 자바스크립트를 통해 비동기 처리를 하는 것을 보고 멀티 쓰레드를 지원한다고 생각할 수도 있지만, 이는 사실 실행할 코드들을 쌓아뒀다가 순차적으로 실행하는 것에 불과하다. 이러한 환경상의 제약때문에, 스칼라JS에는 스칼라(자바) 표준 라이브러리에 있는 쓰레드 제어 메서드들이 구현되지 못했다. 예컨대 Thread.sleep()을 사용한 코드는 .js 파일로 최종 변환하는 시점에 linking 오류가 발생한다. 다행히 EC의 정의는 코드를 비동기로 실행할 수만 있으면 되기 때문에, 스칼라.js에서는 자바스크립트의 setTimeout이나 Promise를 활용해 EC 구현체들을 제공할 수 있었다.

구체적으로 말하자면, 스칼라.js에서는 QueueExecutionContext라는 EC를 제공하며, 이를 JSExecutionContext.Implicits.queue 변수를 임포트해 암묵 참조할 수 있다. 최근 버전에서는 기존의 ExecutionContext.Implicits.global 또한 동일한 객체를 가리키는 레퍼런스가 되어 이걸 써도 된다. 그밖에 RunNowExecutionContext라는 다른 EC도 존재하나, 이것은 코드를 바로 실행해버리는 것이라 비동기 실행이라는 EC의 사양을 충족시키기 못하기 때문에 0.6.6에서 Deprecated 되었다. QueueExecutionContext는 정확히는 EC 구현체가 아니라 조건에 따라 다른 두 개의 구현체를 제공하는 객체이다. 하나는 HTML5의 Promise API에 기반한 PromisesExecutionContext이며, 다른 하나는 Promise 를 지원하지 않을 경우에 대안으로 setTimeout을 이용해 비동기 코드를 실행하는 TimeoutsExecutionContext이다. 하지만 어느 쪽이건 예상되는 동작은 같고 패키지 외부에서 접근도 불가능하기에 원칙적으로 사용자는 신경 쓸 필요가 없다. 다만 소스코드가 간단해 살펴보는 것도 어렵지 않다.

// PromisesExecutionContext의 execute 메서드
    def execute(runnable: Runnable): Unit = {
      resolvedUnitPromise.`then` { (_: Unit) =>
        try {
          runnable.run()
        } catch {
          case t: Throwable => reportFailure(t)
        }
        (): Unit | js.Thenable[Unit]
      }
    }

// TimeoutsExecutionContext의 execute 메서드
    def execute(runnable: Runnable): Unit = {
      js.Dynamic.global.setTimeout({ () =>
        try {
          runnable.run()
        } catch {
          case t: Throwable => reportFailure(t)
        }
      }, 0)
    }

하지만 이렇게 논 블로킹으로 코드를 실행할 수는 있어도, 자바스크립트 환경이기 때문에 발생하는 한계가 존재한다. 바로 비동기 코드를 동기적으로 실행할 수 없다는 것이다. 쉽게 말해 Await.result와 같은 API를 사용할 수 없다. 이러한 API가 동작하려면 비동기로 실행된 코드가 그 실행된 컨텍스트에서 실행을 마칠 때까지 현재 컨텍스트를 대기시켜야 하는데, 만일 JVM 기반이라면 비동기 코드가 실행되고 있는 쓰레드에서 응답이 올 때까지 현재 쓰레드를 중단시켜버리면 그만이다. 그러나 JS 환경이라면 이후 실행될 모든 코드를 콜백으로 등록하거나 Promise의 체인에 묶어버리지 않고서는 비동기 코드의 실행을 막지 않고 현재 컨텍스트의 실행을 중단시킬 방법이 없다. 블로킹을 위한 API가 없기 때문이다.

스칼라테스트 3.0을 이용한 스칼라.js의 비동기 코드 테스트

이것이 왜 문제가 되는가? 일반적인 클라이언트 프로그래밍에서는 문제가 되지 않을 수도 있다. 하지만 테스트를 작성하는 경우를 가정해 보자. 서버에 현재 집계된 숫자를 질의하는 def count():Future[Int]라는 메서드가 있다고 할때, 테스트를 작성한다면 아마도 다음과 같이 작성하고 싶을 것이다.

"Server.count()" should "return the correct count" in {
  val future = server.count()
  val result = Await.result(future, 1 seconds)
  future should be (1)
}

하지만 스칼라.js에서는 Await.result를 사용할 수 없기 때문에 위와 같은 테스트 작성은 불가능하다. 물론 원격 API 호출에 특정 자바스크립트 프레임워크를 사용한다면 그 프레임워크에서 제시하는 방법을 써도 된다(예컨대 AngularJS의 $httpBackend.flush()). 하지만 최신의 스칼라테스트(3.0)를 사용하면 다른 방식으로 일반화해서 해결할 수도 있다.

스칼라테스트 3.0에는 여러 변화들이 있지만, 가장 눈에 띄는 것은 스칼라.js 지원과 비동기 방식 테스트의 도입이다. 비동기 방식 테스트를 이용하면 테스트 케이스의 코드 전체가 비동기적으로 실행되어도 된다. 이러한 방식에서는 위에 예로 든 테스트 코드가 다음과 같이 작성될 수 있다.

"Server.count()" should "return the correct count" in {
  server.count() map { _ should be (1) }
}

일견 기존의 whenReady와 비슷해 보이지만, 테스트 케이스 안에서 반환되는 내용이 최종적으로 Future라는 점이 다르다. 정확히는 Future[Assertion]이다. 비동기 테스트를 사용하기 위해서는 3.0에서 새롭게 제공되는 AsyncFunSuite, AsyncFunSpec, AsyncFlatSpec 등 Async로 시작하는 트레이트로 테스트 클래스를 구현해야 한다. 그리고 테스트 케이스에 사용되는 코드에도 당연히 EC가 필요한데, 이들 트레이트에는 스칼라테스트의 SerialExecutionContext라는 EC가 암묵 변수로 이미 등록되어 있기 때문에 따로 import하지 않아도 된다. 물론, 필요하다면 executionContext 변수를 오버라이드해 EC를 다른 구현체로 변경하는 것도 가능하다.

마지막으로 알아두면 유용한 사용례들을 알아보고자 한다.

"Server.updatePassword()" should "update the existing password with the given value" {
  server.updatePassword("newPassword") map { _ => succeed }
}

여기서 updatePassword()는 성공시 Unit을 반환하며 실패시 예외를 던지는 메서드이다. succeed는 성공을 의미하는 Assertion이다. 만일 updatePassword가 실행 도중 예외를 발생시킨다면 Failure[Throwable]이 반환되고 map 이후 함수는 실행될 일이 없으므로 이 테스트는 자연 실패하게 된다.

it should "throw an IllegalArgumentException when empty or null id is specified" in {
  recoverToSucceededIf[IllegalArgumentException] {
    server.updatePassword(null)
  }
}

recoverToSucceededIf 문은 Future[Any]를 받기 때문에 반드시 Assertion을 반환하지 않아도 된다. 이 메서드는 실행된 블록에서 예상되는 예외가 반환되는지를 검사한다. 단, 헷갈리지 않길 바란다. 만일 updatePassword 메서드에서 Failure[IllegalArgumentException]를 '반환'하는 것이 아니라 예외만 메서드 바깥으로 '던져'버린다면 이 테스트에서는 아예 오류가 발생할 것이다. 이 경우에는 기존의 테스트 구문이 필요하다.

그 밖의 필요한 사용법들은 새로 추가된 Async로 시작하는 트레이트들의 API 문서에 정말 상세하게 적혀 있기 때문에 일일이 여기서 설명할 필요는 없을 것이다. 이 글을 쓰는 현재 스칼라테스트 3.0은 정식이 아닌 RC4에 머물러 있기 때문에, 최종 버전에서는 사소한 변경이 있을 수도 있다.

Tag :
, , , ,

Leave Comments