원문: Effective Scala - Twitter's standard libraries
Twitter에서 가장 중요한 표준 라이브러리는 Util과 Finagle입니다. Util은 Scala와 Java 표준 라이브러리를 확장하여, 누락된 기능이나 더 적절한 구현을 제공합니다. Finagle은 Twitter의 RPC 시스템으로, 분산 시스템의 핵심 컴포넌트입니다.
Futures
Futures는 이전의 Concurrency 부분에서 간략히 다루어 보았습니다. Futures는 비동기 프로세스를 조정하는 주요 메커니즘으로 Twitter의 코드베이스에서 널리 사용되며, Finagle의 핵심입니다. Futures는 동시성 이벤트를 조합할 수 있게 해주고, 동시성 높은 작업에 대한 사고를 단순화해줍니다. 이들은 또한 JVM에서 매우 효율적인 구현을 위한 요소로 존재합니다.
Twitter의 Future는 비동기적이며, 어떠한 쓰레드의 실행을 방해하는 블로킹 작업인 네트워크 IO나 디스크 IO 같은 작업은 Future를 제공하는 시스템에 의해 처리되어야 합니다. Finagle은 이러한 네트워크 IO 같은 작업을 위한 도구를 지원해줍니다.
Futures는 순수하고 간단합니다. 이는 아직 완료되지 않은 계산의 결과를 나타내는 약속을 보유하는 간단한 컨테이너입니다. 계산은 당연히 실패할 수 있고, 이러한 실패도 컨테이너에 포함됩니다. Future는 세 가지 상태(pending, failed, completed) 중 하나일 수 있습니다.
Aside: Composition
조합 (composition) 에 대해 다시 한 번 알아봅시다.
조합은 간단한 계산을 조합해 복잡한 계산을 만들어내는 것입니다. 전형적인 예시는 함수의 조합입니다. f와 g라는 함수가 존재하고, (g·f)(x) = g(f(x)) 라고 표현됩니다. 이는 f 함수를 x에 먼저 적용하고(apply), 그 결과를 다시 g 함수에 적용하는 것입니다. 이는 scala로 아래처럼 표현됩니다.
val f = (i: Int) => i.toString
val g = (s: String) => s + s + s
val h = g compose f // : Int => String
assert(h(123) == 123123123)
함수 h는 이미 정의된 함수 g와 f를 조합한 함수입니다.
Futures는 collection의 일종입니다. 이는 0개 또는 1개의 원소를 가집니다. Future 역시 일반적인 collection 함수를 가집니다(map, filter, foreach). Future의 값은 지연되어 계산되므로, 이러한 메소드를 적용한 결과도 지연되어 계산됩니다.
val result: Future[Int]
// i => i.toString은 i값이 제공되기 전까지 계산되지 않습니다
// 따라서 resultStr의 최종값도 i값이 제공되기 전까지 계산되지 않습니다
val resultStr: Future[String] = result map { i => i.toString }
List는 flatten 연산이 가능하며, 이는 Future에도 적용됩니다.
// list
val listOfList: List[List[Int]] = ???
val list: List[Int] = listOfList.flatten
// future
val futureOfFuture: Future[Future[Int]] = ???
val future: Future[Int] = futureOfFuture.flatten
Future는 지연 연산을 지원하므로, flatten 구현은 가장 바깥의 future의 결과가 끝날때까지 대기 한 후(Future[Future[Int]]) 안쪽의 future가 끝나는 것을 대기합니다. 만약 바깥의 future가 실패하면, flatten future 역시 실패합니다.
Futures는 List처럼 flatMap 역시 지원합니다.
flatMap[B](f: A => Future[B]): Future[B]
이는 map과 flatten의 조합이며, 아래처럼 구현할 수 있습니다.
def flatMap[B](f: A => Future[B]): Future[B] = {
val mapped: Future[Future[B]] = this map f
val flattened: Future[B] = mapped.flatten
flattened
}
이는 매우 강력한 조합입니다! 우리는 flatMap을 사용해 두 future를 순서대로 계산한 future를 정의할 수 있으며, 두 번째 future는 첫 번째 future의 결과에 의존합니다. 만약 두 RPC를 사용해 사용자를 인증하려 할 때, 두 RPC의 조합은 아래처럼 만들 수 있습니다.
def getUser(id: Int): Future[User]
def authenticate(user: User): Future[Boolean]
def isIdAuthed(id: Int): Future[Boolean] =
getUser(id) flatMap { user => authenticate(user) }
이러한 조합의 또 다른 이점은 에러 처리가 내장되어 있다는 것입니다. 어떠한 추가적인 처리를 하지 않아도 isAuthed 함수는 getUser와 authenticate 함수 어느 하나라도 실패하면 실패하게 됩니다.
Style
Future의 콜백 함수는 (respond, onSuccess, onFailure, ensure) 부모와 연결된 새로운 future를 반환합니다. 이 future는 부모 future가 종료된 이후에야 종료됨을 보장하며, 아래와 같은 패턴을 보장해줍니다.
acquireResource() onSuccess { value =>
computeSomething(value)
} ensure {
freeResource()
}
freeSource() 함수는 computeSomething이 끝난 후에 단 한 번 호출되는 것을 보장해주며, try ... finally 패턴과 유사하게 실행됩니다.
foreach 대신 onSuccess를 사용하세요. onSuccess는 onFailure의 대칭 함수이며, 그 목적에 더욱 알맞은 이름을 가지고 연결(chaining)도 지원해줍니다.
언제든지 Promise를 직접 만드는 것은 지양하세요. 거의 대부분의 작업은 이미 정의된 조합들로 구현할 수 있습니다. 이들은 에러와 취소가 전파되는 것을 보장해주며, 동기화나 가시성 선언이 필요 없는 데이터 흐름 스타일 프로그래밍을 장려합니다.
꼬리 재귀 스타일로 작성된 코드는 스택 공간 누수를 방지하여 효율적인 루프 구현이 가능하게 해줍니다.
case class Node(parent: Option[Node], ...)
def getNode(id: Int): Future[Node] = ???
def getHierarchy(id: Int, nodes: List[Node] = Nil): Future[Node] =
getNode(id) flatMap {
case n @ Node(Some(parent), ...) => getHierarchy(parent, n :: nodes)
case n => Future.value((n :: nodes).reverse)
}
Future에는 많은 유용한 메서드가 있습니다. Future.value()와 Future.exception()을 사용하여 미리 완료된 Future를 생성할 수 있습니다. Future.collect(), Future.join(), Future.select()은 여러 Future를 하나로 결합하는 조합을 제공합니다.
Cancellation
Future는 약한 형태(weak form)의 취소를 구현합니다. Future#cancel을 호출해도 계산이 바로 종료되지 않고, 대신 최종적으로 Future를 만족시키는 프로세스가 조회할 수 있는 신호가 전파됩니다. 취소 신호는 값과는 반대되는 방향으로 전파되며, 소비자가 설정한 취소 신호가 생산자에게 전파됩니다. 생산자는 Promise의 onCancellation을 사용하여 이 신호를 수신하고 이에 따라 작업을 수행합니다.
이는 취소의 의미가 생산자에 따라 다르며, 기본 구현이 없음을 의미합니다. 취소는 단지 "힌트"일 뿐입니다.