원문: Effective Scala - Concurrency
현대의 서비스는 매우 높은 동시성을 유지합니다. 서버가 수만에서 수십만개까지 동시 작업을 조정하는 것은 흔한 일이며, 이러한 복잡성을 처리하는 것은 견고한 시스템 소프트웨어를 작성하는 데 필수적인 요소입니다.
쓰레드는 동시성을 표현하는 수단을 제공합니다. 쓰레드는 독립적이고 힙을 공유하는 실행 컨텍스트를 제공하며, 이는 운영 체제가 스케줄링합니다. 그러나 쓰레드의 생성은 Java에 있어서 매우 비용이 많이 드는 작업으로, 반드시 리소스 풀을 이용해 관리되어야 합니다. 이는 프로그래머에게 추가적인 복잡성을 야기하며, 높은 수준의 커플링을 일으킵니다. 특히, 어플리케이션의 로직을 쓰레드 사용에서 분리하기 어렵게 합니다.
이러한 복잡성은 특히 서비스의 요청이 여러 시스템 계층에 퍼져나가는(fan-out) 상황에서 더욱 두드러집니다. 이러한 시스템에서 쓰레드 풀은 반드시 관리되어야 하며, 각 계층의 요청에 따라 적절한 비율로 유지되어야 합니다. 하나의 쓰레드 풀을 잘못 관리하면 다른 풀에도 영향을 미치게 됩니다.
예민한 시스템은 타임아웃과 취소도 적절히 고려되어야 합니다. 이 두 작업은 쓰레드에 대한 더 상세한 "조작"을 요구하며 복잡성을 가중시킵니다. 쓰레드의 비용이 저렴하다면 이러한 고려사항은 무시될 수 있습니다. 쓰레드 풀이 불필요해지고, 타임아웃된 쓰레드는 버리면 되며, 추가적인 리소스 관리도 필요 없을 테니까요.
결국 리소스 관리는 모듈성을 저해합니다.
Futures
Future를 사용해 동시성을 관리하세요. Future는 리소스 관리와 동시성 작업을 분리시켜 줍니다. 예를 들어, Finagle은 소수의 스레드를 이용해 여러 동시 작업을 효율적으로 멀티플렉싱합니다. Scala는 경량의 closure literal 문법을 제공해주어 Future의 문법적인 부담을 줄여주고, 프로그래머는 이를 통해 Future를 자연스럽게 사용할 수 있습니다.
Future는 프로그래머가 선언적인 방식으로 동시성 작업을 표현할 수 있게 만들어주고, 합성 가능하게 하며, 실패 처리를 체계적으로 할 수 있게 만들어줍니다. 이러한 특성은 Future가 특히 함수형 프로그래밍 언어에서 사용하기에 매우 적합하고 권장되는 스타일이라고 알려준다고 할 수 있습니다.
Future의 결과값을 변환하는 걸 우선시하고 직접 뭔가를 만들지 마세요. Future 변환은 실패가 전파되는 것을 보장해주고, 실패 신호를 전달해주며, Java 메모리 모델을 생각하지 않고 프로그래밍 할 수 있게 만들어줍니다. 예를 들어, 실력있는 프로그래머가 특정 RPC 호출을 10회 반복하는 코드를 짰다고 해봅시다.
val p = new Promise[List[Result]]
var results: List[Result] = Nil
// rpc 작업이 성공하면 10회까지 results에 저장하고
// 모두 성공하면 Promise에 결과를 저장하고
// 한 번이라도 실패하면 Promise에 실패를 알려줍니다.
def collect() {
doRpc() onSuccess { result =>
results = result :: results
if (results.length < 10)
collect()
else
p.setValue(results)
} onFailure { t =>
p.setException(t)
}
}
collect()
p onSuccess { results =>
printf("Got results: %s\n", results.mkString(", "))
}
프로그래머는 RPC 실패가 전파되는 것을 보장해야 하고, control flow에 따라 코드가 분산되어 있습니다. 더 나쁜 점은, 이 코드가 틀렸다는 겁니다! results를 volatile로 선언하지 않으면 각 iteration마다 results가 이전에 저장한 값을 가지고 있는지 보장할 수 없습니다. 자바 메모리 모델은 이해할 수 없는 짐승입니다. 하지만, 우리는 이러한 함정을 선언적인 스타일로 작성해서 피할 수 있습니다.
// 재귀적인 스타일로 작성되어 volatile도 필요없습니다
def collect(results: List[Result] = Nil): Future[List[Result]] =
doRpc() flatMap { result =>
if (results.length < 9)
collect(result :: results)
else
Future.value(result :: results)
}
collect() onSuccess { results =>
printf("Got results %s\n", results.mkString(", "))
}
우리는 flatMap을 사용하여 연산을 순차적으로 처리하고 결과를 목록 끝에 추가하였습니다. 이런 방식은 함수형 프로그래밍의 일반적인 패턴을 Future에 적용한 것입니다. 이는 올바르고, 더욱 적은 boilerplate를 요구하며, 오류도 덜 발생하고, 가독성도 좋습니다.
Future 조합자(combinators)를 사용하세요. Future.select, Future.join, Future.collect는 여러 Future를 결합할 때 자주 사용되는 패턴을 공식화한 것입니다 (Twitter github의 util에서 확인할 수 있습니다).
Future를 반환하는 메소드에서 예외를 던지지 마세요. Future는 성공과 실패를 모두 나타내는 타입입니다. 따라서, 계산에서 나타날 수 있는 에러는 반드시 반환되는 Future 안에서 적절히 캡슐화되어야 합니다. 에러를 직접 던지지 말고 Future.exception을 사용해서 반환하세요(이러한 코드들은 전부 위의 twitter util 라이브러리를 사용한 것입니다).
def divide(x: Int, y: Int): Future[Result] = {
if (y == 0)
return Future.exception(new IllegalArgumentException("Divisor is 0"))
Future.value(x / y)
}
Fatal exception은 Future로 나타내지 마세요. Fatal exception은 리소스를 모두 사용했거나 (Out of memory 같은 에러) JVM 레벨의 NoSuchMethodError 같은 것을 의미합니다. 이러한 exception은 JVM이 종료되어야 하는 상황입니다.
scala.util.control.NonFatal 또는 twitter 라이브러리 버전인 com.twitter.util.NonFatal과 같은 예외 식별자를 사용하여 Future.exception으로 처리할 예외를 구분하세요.
Collections (컬렉션)
동시성 컬렉션에 대한 논의는 다양한 의견이 있으며, 예민하고, 독단적인 견해나 공포, 불확실성, 의심으로 가득합니다. 그러나 대부분의 상황에서 이것은 문제가 되지 않습니다. 항상 목표를 달성할 수 있는 가장 단순하면서 표준적인 컬렉션을 사용하세요.
주어진 문제를 synchronized를 사용하는 것 만으로 해결할 수 없다고 알게되기 전까지 동시성 컬렉션을 사용하지 마세요. JVM은 동기화를 저렴하게 처리할 수 있는 고급 메커니즘을 가지고 있습니다. 이 효율성을 보게되면 놀랄 수도 있습니다.
만약 불변 컬렉션을 사용할 수 있다면 사용하려고 노력하세요. 불변 컬렉션은 참조 투명성을 제공해주어 동시성 컨텍스트에서 다루기 쉽습니다. 불변 컬렉션 내의 변화는 일반적으로 현재 값의 참조를 업데이트하여 처리합니다 (var cell이나 AtomicReference 내에서 조작하게 됩니다). 이를 정확하게 적용하기 위해 노력하세요. 원자적인 정보는 반드시 재시도되어야하고, var 변수는 volatile로 선언되어 다른 쓰레드에서 정확히 참조될 수 있게 해야 합니다. (컬렉션 내의 데이터가 많지 않다면 컬렉션의 copy 메소드를 사용해 복사해서 수정하면 됩니다. 다만 값이 아니라 인스턴스일 경우에는 위의 조언처럼 수정해야 합니다.)
가변적인 동시성 컬렉션은 복잡한 구조를 가지고 있고 Java 메모리 모델의 섬세한 측면을 활용해서 만들어졌습니다. 따라서 사용에 있어 반드시 주의해야합니다. 특히, 업데이트를 알리는 부분에 있어 신중히 처리해야 합니다. 동기화된(Synchronized) 컬렉션은 더 잘 조합될 수 있습니다. getOrElseUpdate 같은 작업은 동시성 컬렉션으로 올바르게 구현할 수 없으며, 복합 컬렉션을 만드는 것은 특히 오류가 발생하기 쉽습니다.