devcken.io

Thoughts, stories and ideas.

Overcoming type erasure in Scala

원문: Overcoming type erasure in Scala

이 글을 통해 스칼라의 타입 이레이저로 인해 흔히 발생되는 몇 가지 문제들을 다루기 위한 서너개의 기술들을 소개하고자 합니다.

소개

스칼라는 정말 강력한 타입 시스템을 갖추고 있습니다. 존재하는 타입, 구조적 타입, 내재화된 타입, 경로 기반의 타입, 추상 및 구체 타입 멤버, 타입 바운드(상위, 하위, 뷰, 컨텍스트), 사용 위치 및 선언 위치 타입 변성, 타입 다형성을 위한 지원(서브타입, 파라메터, F-바운드, 애드혹), 고계 타입, 보편적 타입 제약 등 셀 수 없을 정도입니다.

스칼라의 타입 시스템이 이론적으로 매우 강력하다고 해도, 런타임 환경의 제약과 제한으로 인해 실제로는 일부 타입 관련 기능들이 약화됩니다. 맞아요. 타입 이레이저에 대해 이야기하려고 하는 것입니다.

타입 이레이저란 무엇일까요? 음, 간단히 말하자면, 자바와 스칼라 컴파일러에 의해 실행된 프로시저로 컴파일 후 모든 제네릭 타입 정보를 제거합니다. 즉, 예를 들면, 런타임에 List[Int]List[String] 간의 차이를 구별할 수 없다는 것을 의미합니다. 컴파일러는 왜 그렇게 할까요? 음, 자바 가상 머신(자바와 스칼라를 실행하는 내재된 런타임 환경)이 제네릭에 대해 어떤 것도 알고 있지 않기 때문입니다.

타입 이레이저는 역사적인 이유로 생겨났습니다. 자바가 처음부터 제네릭을 지원한 것은 아닙니다. 그래서 자바 5에 추가됐을 때, 하위 호환성을 유지해야만 했습니다. 과거의, 제네릭하지 않은 레거시 코드와 무리없이 인터페이스할 수 있길 원했습니다(이것이 바로 자바가 로우 타입을 갖는 이유입니다). 내부에서는 제네릭 클래스의 타입 파라메터가 객체와 상위 바운드로 대체됩니다. 예를 들면:

class Foo[T] {  
  val foo: T
}
class Bar[T <: Something] {  
  val bar: T
}

는 다음과 같이 변경됩니다

class Foo {  
  val foo: Object
}
class Bar {  
  val bar: Something
}

보다시피, 런타임은 제네릭 클래스가 파라메터화된 실제 클래스에 대해 알지 못합니다. 예제에서, 가공되지 않은 FooBar만 알 수 있을 뿐입니다.

타입 이레이저가 누군가의 무능함 또는 무지 등으로 인해 만들어진 산물이라고 생각하지 마십시오. 그것은 나쁜 설계가 아닙니다(누군가 충분히 영리하지 못하거나 충분히 유능하지 못한 사람의 산물이라고 한적이 있습니다). 그것은 의도된 트레이드 오프입니다. 소스, 바이너리 그리고 동작 호환성을 다룰 때 고려해야 할 많은 것들이 존재하며, 자바 담당자들은 이를 위해 많은 시간을 들였고 그들이 생각할 수 있는 최상의 작업을 해냈습니다. 개인적으로, 그냥 하위 호환성을 깨고 이후의 자바 릴리즈의 제네릭을 사용하도록 강제하는 것이 장기적으로는 더 나은 결정이라고 생각합니다. 그러나 비지니스 측면에서, 그들의 결정은 충분히 이해할 만합니다. 고객의 중요한 부분들을 복잡하게 만들어버리는 것(그리고 어쩌면 고객을 화나게 만들 수도 있는 것)을 선택하기란 쉽지 않기 때문입니다.

어쨌든, 본론으로 돌아가서, 스칼라에서 타입 이레이저를 다룰 수 있는 방법에 대해 이야기하고자 합니다. 불행하게도, 타입 이레이저 자체를 막을 방법은 없지만, 몇 가지 해결 방법에 대해 알아보도록 하겠습니다.

동작 방식(또는 동작하지 않는 방식)

다음은 타입 이레이저에 대한 간단한 예제입니다:

object Extractor {  
  def extract[T](list: List[Any]) = list.flatMap {
    case element: T => Some(element)
    case _ => None
  }
}

val list = List(1, "string1", List(), "string2")  
val result = Extractor.extract[String](list)  
println(result) // List(1, string1, List(), string2)  

extract() 메서드는 모든 종류의 객체에 대한 리스트를 받습니다. Any 타입을 가지고 있어서, 숫자, 부울 값, 문자열, 바나나, 오렌지 등 무엇이든지 넣을 수 있습니다. 어쨌든, 코드 내에서 List[Any]가 나오면 "코드 냄새"를 바로 맡을 수 있어야 하지만, 잠시 모범 사례는 제쳐두고 타입 이레이저와 관련된 문제에만 집중하도록 하겠습니다.

우리가 하고자 하는 것은 혼합된 객체 리스트를 받고 특정 타입의 객체만 추출하는 것입니다. 그러한 타입으로 extract() 메서드를 파라메터화하도록 타입을 선택할 수 있습니다. 주어진 예제에서 선택된 타입은 String인데, 이는 주어진 리스트에서 모든 문자열을 추출하려고 한다는 것을 의미합니다.

(런타임 세부 내용과는 관련없이) 엄격한 언어 관점에서 이 코드는 합리적입니다. 패턴 매칭으로 주어진 객체를 분해하는 과정없이 타입을 알아낼 수 있습니다. 하지만, JVM에서 실행되고 있는 프로그램이라는 점에서, 모든 제네릭 타입은 컴파일 이후에 지워질 것입니다. 그러므로 패턴 매칭은 실제로 오래가지 못합니다. 타입의 "일급 수준"을 넘어서는 모든 것이 삭제되기 때문입니다. 변수를 Int 또는 String(또는 MyNonGenericClass와 같이 제네릭하지 않은 모든 타입)에 매치시키는 것은 잘 동작하지만, T를 제네릭 파라메터라고 할 때, T에 매치시키는 것은 불가능합니다. 컴파일러는 "abstract type pattern T is unchecked since it is eliminated by erasure"라는 경고를 줄 것입니다.

이러한 상황에 대해 도움을 주고자, 스칼라는 2.7 버전쯤부터 Manifest를 도입했습니다. 하지만, 특정 타입을 표현할 수 없다는 문제를 가지고 있었고 스칼라 2.10 에서 좀 더 강력한 [TypeTag](http://docs.scala-lang.org/overviews/reflection/typetags-manifests.html)로 대체되었습니다.

타입 태그는 다음과 같이 세 가지 부류로 나뉩니다:

  • TypeTag
  • ClassTag
  • WeakTypeTag

이것이 문서의 공식 분류이긴 하지만, 제 생각에는 다음과 같이 분류하는 것이 더 나아보입니다:

  • TypeTag:
    • "classic"
    • WeakTypeTag
  • ClassTag

TypeTag와 WeakTypeTag가 실제로 (나중에 설명할) 한가지 중요한 차이점만 있는 동일한 두 가지 유형인 반면 ClassTag는 상당히 다른 구조라는 점을 강조하고자 합니다.

ClassTag

추출기 예제로 돌아가서 타입 이레이저 문자를 수정하는 방법에 대해 살펴보겠습니다. 단일한 암시 파라메터를 extract() 메서드에 추가하는 것이 전부입니다:

import scala.reflect.ClassTag  
object Extractor {  
  def extract[T](list: List[Any])(implicit tag: ClassTag[T]) =
    list.flatMap {
      case element: T => Some(element)
      case _ => None
    }
}
val list: List[Any] = List(1, "string1", List(), "string2")  
val result = Extractor.extract[String](list)  
println(result) // List(string1, string2)  

짜잔! 갑자기 "List(string1, string2)"라고 표시될 겁니다. 물론 여기서 컨텍스트 바운드 문법을 사용할 수도 있습니다:

// def extract[T](list: List[Any])(implicit tag: ClassTag[T]) =
def extract[T : ClassTag](list: List[Any]) =  

코드를 가능한 선명하게 보이도록 어떤 문법적 편의도 사용하지 않고 표준 문법을 사용할 겁니다.

어떻게 동작하나요? 음, ClassTag 타입인 암시 값을 요구하면 컴파일러가 그 값을 생성합니다. 문서에서는 이렇게 말하고 있습니다:

u.ClassTag[T] 타입의 암시 값이 요구되는 경우, 컴파일러는 필요에 따라 하나의 값을 만들어 냅니다.

그래서, 컴파일러는 요구되는 ClassTag의 암시 인스턴스를 기꺼이 제공하며, 우리는 그냥 요구하면 됩니다. 또한 이러한 메커니즘은 TypeTagWeakTypeTag에도 사용됩니다.

좋아요, extract() 메서드에서 사용 가능한 암시 ClassTag 값을 갖게 되었습니다(고마워요, 컴파일러). 메서드 본문 내로 들어가면 무슨 일이 벌어질까요? 다시 한번 예제를 보시길 바랍니다. 컴파일러는 자동으로 암시 파라메터 태그에 대한 값을 우리에게 제공해주었을 뿐(그 자체로도 훌륭합니다), 결코 해당 파라메터 자체를 사용한 적은 없습니다. "태그" 값으로 절대 어떤 일도 해서는 안됩니다. 그것은 패턴 매칭이 리스트의 문자열 요소에 성공적으로 매치되도록 하는 존재에 불과합니다. 좋아요, 그것은 컴파일러의 매우 훌륭한 점이지만, 너무 "마법같은 일"들이 계속되고 있는 것처럼 보입니다. 더 자세히 살펴보겠습니다.

설명을 찾아보기 위해 문서를 볼 수도 있지만, 실제로는 여기에 숨어있습니다:

컴파일러는 a(_: T) 타입 패턴을 ct(_: T)으로 래핑하여 패턴 매치의 확인되지 않은 타입 테스트를 확인된 것으로 바꾸려고 합니다. 여기서 ctClassTag[T]의 인스턴스를 말합니다.

기본적으로는 암시 ClassTag를 컴파일러에게 제공하면, 주어진 태그를 추출기(extractor, 역자: 예제의 extractor가 아닌 패턴 매치의 extractor를 말함)를 사용하도록 패턴 매칭의 조건을 다시 작성합니다. 다음 조건

case element: T => Some(element)  

는 컴파일러에 의해 (스코프 내에 암시 태그가 존재하는 경우) 다음과 같이 변환됩니다:

case (element @ tag(_: T)) => Some(element)  

여러분이 "@" 구조를 이전에 보지 못했을 수도 있는데, 매치하고 있는 클래스에 이름을 부여하는 방법일 뿐입니다. 예를 들어:

case Foo(p, q) => // we can only reference parameters via p and q  
case f @ Foo(p, q) => // we can reference the whole object via f  

사용할 T 타입에 대해 사용 가능한 암시 ClassTag가 없는 경우, (타입 정보의 결여로 인해) 컴파일러는 작동하지 않을 것이고 패턴 매칭이 T 타입에 대해 타입 이레이저를 겪을 것이라는 경고를 내보냅니다. 컴파일이 중단되지는 않지만, 패턴 매칭에 도달했을 때 패턴 매칭을 할 때 T가 무엇인지를 컴파일러가 알 거라고 기대할 수 없습니다(JVM에 의해 런타임이 타입이 지워지기 때문에). 만약 타입 T에 대해 암시 ClassTag를 제공할 경우, 컴파일러는 예제에서 보았던 것처럼 컴파일 시점에 적합한 ClassTag를 기꺼이 제공할 것입니다. 태그는 String이 될 T에 관한 정보를 줄 것이며 타입 이레이저는 그것을 건들이지 못합니다.

좋지 않나요? 그러나 중대한 약점이 한 가지 있습니다. 고차 수준의 타입을 구분하여 초기 리스트에서 예를 들어 List[String]는 무시하면서 List[Int] 값을 가져오려는 경우, 그와 같이 할 수는 없습니다:

val list: List[List[Any]] = List(List(1, 2), List("a", "b"))  
val result = Extractor.extract[List[Int]](list)  
println(result) // List(List(1, 2), List(a, b))  

웁스! 오직 List[Int]만 추출하려고 했는데, List[String] 역시 추출되었습니다. 클래스 태그는 고차 수준을 구별할 수 없습니다. 최상위 수준만 가능합니다. 즉, 추출기는 예를 들어 세트와 리스트를 구분할 수 있지만, 한 리스트를 다른 리스트와 구분할 수는 없습니다(예를 들어 List[Int] vs List[String]). 물론 리스트에만 해당되는 얘기는 아니며 모든 제네릭 트레이트/클래스에도 해당되는 얘기입니다.

TypeTag

ClassTag가 실패하는 경우, TypeTag는 훌륭하게 성공합니다. List[String]List[Integer]와 구분합니다. List[Set[Int]]List[Set[String]]와 구분하듯이, 더 깊게 들어갈 수 있습니다. TypeTag가 런타임에 제네릭 타입에 관한 더 풍부한 정보를 가지고 있기에 가능합니다. 문제가 되는 타입의 전체 경로뿐만 아니라 (존재한다면) 모든 내재화된 타입까지도 쉽게 가져올 수 있습니다. 주어진 태그에 tpe()을 호출하기만 하면 이러한 정보를 얻을 수 있습니다.

다음 예제에서, ClassTag의 경우와 마찬가지로 암시 태그 파라메터가 컴파일러에 의해 주어집니다. "args" 인자에 주목하시기 바랍니다. 이 인자는 ClassTag가 가지고 있지 않은 추가적인 타입 정보(Int로 파라메터화된 List에 대한 정보)를 가진 인자입니다.

import scala.reflect.runtime.universe._  
object Recognizer {  
  def recognize[T](x: T)(implicit tag: TypeTag[T]): String =
    tag.tpe match {
      case TypeRef(utype, usymbol, args) =>
        List(utype, usymbol, args).mkString("\n")
    }
}

val list: List[Int] = List(1, 2)  
val result = Recognizer.recognize(list)  
println(result)  
// prints:
//   scala.type
//   type List
//   List(Int)

(어쩌면 의존성을 추가해야 할 수도 있습니다).

여기서 새로운 객체가 도입되었습니다. 바로 Recognizer입니다. 괜찮은 예전 Extractor에 무슨 일이 생겼나요? 음, 슬픈 소식입니다. TypeTags를 사용해 Extractor를 구현할 수 없습니다. 좋은 점은 고차 타입에 관해 알고 있듯이(즉, List[X]List[Y]와 구분하는 것), 타입에 관한 더 많은 정보를 가지고 있는 것인데, 단점은 런타임 객체에 사용할 수 없다는 것입니다. 우리는 TypeTag를 사용해 런타임에 특정 타입에 관한 정보를 가져올 수는 있지만, 런타임 시 어떤 객체의 타입을 알아내기 위해 사용할 수는 없습니다. 차이점을 아시겠나요? recognize()에 전달하는 것은 확실히 List[Int]였습니다. List(1, 2) 값의 선언 타입이었죠. 그러나 List(1, 2)가 만약 List[Any]로 선언되었다면, TypeTagList[Any]가 전달되었다고 알려줄 것입니다.

다음은 ClassTagTypeTag 간의 차이점입니다:

  1. ClassTag는 "고차 타입"에 관해 알지 못합니다. List[T]가 주어지면, ClassTag는 값이 List라는 것을 알뿐 T에 관해서는 알지 못합니다.
  2. TypeTag는 "고차 타입"에 관해 알고 훨씬 더 풍부한 타입 정보를 가지고 있지만, 런타임 시 값에 대한 타입 정보를 얻는데 사용할 수는 없습니다. 다시 말해, TypeTag타입에 관한 런타임 정보를 제공하지만, ClassTag값에 대한 런타임 정보(구체적으로 말해서, 런타임 시 문제가 되는 값의 실제 타입이 무엇인지를 알려주는 정보)를 제공합니다.

ClassTag(Weak)TypeTag 간의 차이점을 떠나 한 가지 더 언급해야 할 것이 있습니다. ClassTag는 고전적이고 훌륭한 오래된 타입 클래스입니다. 그것은 각 타입에 대한 개별적인 정보와 함께 제공되어, 표준 타입 클래스 패턴이 됩니다. 반면, (Weak)TypeTag는 좀 더 정교하며 이전에 사용한 코드 조각에서 알 수 있듯이 코드에 특별한 import를 두어야 합니다. universe를 임포트해야 합니다:

Universe는 멤버쉽 또는 서브타이핑과 같은 스칼라 타입 관계를 리플렉션으로 조사할 수 있도록 하는 리플렉션 연산의 전체 세트를 제공합니다.

걱정하지 마세요, 단순히 universe를 임포트하는 것이 전부입니다. (Weak)TypeTag의 경우에는 scala.reflect.runtime.universe._(문서)를 임포트하세요.

WeakTypeTag

ClassTag와 관련해 지금까지 설명했던 모든 차이점으로 아마도 TypeTagWeakTypeTag가 꽤 유사하다는 인상을 받고 있을 겁니다. 그리고 사실 그렇습니다. 그 둘은 실제로 동일한 도구의 두 가지 변형입니다. 그러나, 중요한 차이점이 존재합니다.

TypeTag가 타입 뿐만 아니라 타입의 파라메터 그리고 그들의 타입 파라메터 까지도 조사할 정도로 충분히 스마트하다는 것을 보았습니다. 하지만, 그러한 모든 타입은 구체(concrete)입니다. 만약 타입이 추상 타입이라면, TypeTag은 그 타입을 해석할 수 없습니다. 이 부분이 WeakTypeTag가 필요한 곳입니다. 잠깐 TypeTag 예제를 수정해보도록 하겠습니다:

val list: List[Int] = List(1, 2)  
val result = Recognizer.recognize(list)  

저기 있는 Int가 보이시나요? String, Set[Double] 또는 MyCustomClass와 같이, 다른 구체 타입도 가질 수 있습니다. 그러나 추상 타입을 가지고 있다면, WeakTypeTag가 필요할 겁니다.

다음이 예제입니다. 추상 타입에 대한 참조를 필요로 하므로 단순히 추상 클래스 내 모든 것을 래핑할 것입니다.

import scala.reflect.runtime.universe._  
abstract class SomeClass[T] {

  object Recognizer {
    def recognize[T](x: T)(implicit tag: WeakTypeTag[T]): String =
      tag.tpe match {
        case TypeRef(utype, usymbol, args) =>
          List(utype, usymbol, args).mkString("\n")
      }
  }

  val list: List[T]
  val result = Recognizer.recognize(list)
  println(result)
}

new SomeClass[Int] { val list = List(1) }  
// prints:
//   scala.type
//   type List
//   List(T)

결과 타입은 List[T]입니다. WeakTypeTag이 아닌 TypeTag를 사용해왔다면, 컴파일러는 "List[T]에 대해 사용 가능한 TypeTag가 없다"라고 불평했을겁니다. 그러므로, WeakTypeTagTypeTag의 슈퍼셋의 한 종류로 볼 수 있습니다.

WeakTypeTag는 가능한 한 구체가 되려고 하기 때문에 일부 추상 타입에 사용 가능한 타입 태그가 존재한다면, WeakTypeTag는 해당 타입 태그를 사용할 것이므로 그것을 추상으로 남겨두는 대신 타입 구체로 만들 것입니다.

결론

끝내기 전에, 각 타입 태그가 사용 가능한 헬퍼를 사용해 명시적으로 초기화할 수도 있다는 점을 언급하고자 합니다:

import scala.reflect.classTag  
import scala.reflect.runtime.universe._

val ct = classTag[String]  
val tt = typeTag[List[Int]]  
val wtt = weakTypeTag[List[Int]]

val array = ct.newArray(3)  
array.update(2, "Third")

println(array.mkString(","))  
println(tt.tpe)  
println(wtt.equals(tt))

//  prints:
//    null,null,Third
//    List[Int]
//    true

그게 전부입니다. 세 가지 구조체, ClassTag, TypeTag 그리고 WeakTypeTag을 보았습니다. 이 구조체들은 여러분의 일상적인 스칼라 생활에서의 대부분의 타입 이레이저 문제를 해결할 것입니다. (원래 내부적으로는 reflection인) 태그 사용은 속도가 느려지고 생성된 코드가 훨씬 커질 수 있으므로 컴파일러를 "경우에 따라" 더 똑똑해지도록 만들기 위해 그리고 실용적인 이유로 라이브러리 전반에 암시적인 타입 태그를 추가하지 마십시오. 정말 필요한 경우에만 두도록 하십시오. 그리고 정말 필요한 경우, JVM의 타입 이레이저에 대한 강력한 무기를 제공할 겁니다.

언제든, sinisalouc@gmail.com로 메일을 보내거나 트위터로 연락을 주시기 바랍니다.

ThreadLocal 변수와 Scala Future

원문: ThreadLocal Variables and Scala Futures

Thread-Local Storage (TLS)는 현재 실행 중인 스레드에 정적 변수를 추가할 수 있도록 해줍니다. TLS의 가장 흔한 용도는 메서드 파라메터없이 콜스택을 통해 글로벌 컨텍스트를 전달하는 것입니다. 덕분에, 웹애플리케이션에서 (현재 요청 URL과 같은) 데이터를 코드 기반을 통해 글로벌하게 이용 가능할 수 있도록 만들어 줍니다(로깅 또는 감사 목적에 매우 유용합니다).

TLS가 실패하는 경우는 스레드 간에 실행 경로가 변경되는 경우입니다. Future가 코드를 병렬화하는 곳이면 어디서나, 모든 TLS가 손실된 비동기 실행을 위한 스레드 풀의 무작위 스레드로부터 실행이 떨어져 나갑니다. Future는 Play!와 같은 새로운 반응형 웹 프레임워크의 심장이기에, 모든 이들이 TLS가 어떻게 동작하는지를 다시 생각해봐야 합니다.

해결책은 좀 더 간단한데, ExecutionContext 트레이트 내에 있습니다. ExecutionContext는 프로그램 로직을 실행할 수 있는 개체에 대한 추상화이며, Future를 만들기 위한 암시적 요구사항입니다:

import scala.concurrent.ExecutionContext.Implicits.global  
val f = Future { /*this block executes in another thread*/ }  

가장 흔한 구현은 ForkJoinPool인데, 효율적인 작업 훔치기 알고리즘을 구현하여 기초 스레드 풀을 향상시킨 것입니다. 그것은 Play!와 Akka를 기반으로 하는 병렬 처리 애플리케이션의 기초입니다.

기초 스레드 정보를 출력하는 작은 프로그램을 살펴보겠습니다:

import scala.concurrent.{ Await, Future }  
import scala.concurrent.duration._

def printThreadInfo(id: String) = println {  
  id + " : " + Thread.currentThread.getName
}

implicit val executionContext = scala.concurrent.ExecutionContext.Implicits.global

printThreadInfo("main")  
val fut1 = Future { printThreadInfo("fut1") }

Await.result(fut1, 1.second)

//Output:
//> main : main
//> fut1 : ForkJoinPool-1-worker-13

그러므로 Future는 확실히 메인 스레드가 아닌 다른 스레드 상에서 실행됩니다. TLS 안에 다른 값들을 저장할 수 있을까요?

여기서는 스칼라에 대해서 이야기하고 있으니, 자바의 ThreadLocal를 직접 사용하지 않고 DynamicVariable 클래스를 사용해보겠습니다. 실행 중인 스레드에 따라 동적으로 값이 주어지는 정적 변수이기에 그렇게 이름지어졌습니다.

DynamicVariablescala.language.dynamics의 동적 필드와는 아무련 관련이 없습니다.

import scala.concurrent.{ Await, Future }  
import scala.concurrent.duration._  
import scala.util.DynamicVariable

def printThreadInfo(id: String) = println {  
  id + " : " + Thread.currentThread.getName + " = " + dyn.value
}

//create a dynamic variable
val dyn = new DynamicVariable[Int](0)

implicit val executionContext = scala.concurrent.ExecutionContext.Implicits.global

val fut1 = dyn.withValue(1) { Future { printThreadInfo("fut1") } }  
val fut2 = dyn.withValue(2) { Future { printThreadInfo("fut2") } }  
val fut3 = dyn.withValue(3) { Future { printThreadInfo("fut3") } }

Await.result(fut1, 1.second)  
Await.result(fut2, 1.second)  
Await.result(fut3, 1.second)

//Output:
//> fut1 : ForkJoinPool-1-worker-13 = 1
//> fut2 : ForkJoinPool-1-worker-11 = 2
//> fut3 : ForkJoinPool-1-worker-9 = 3

//But wait, threads work when created, what happens if we reuse threads already in the pool?

val fut4 = dyn.withValue(4) { Future { printThreadInfo("fut4") } }  
val fut5 = dyn.withValue(5) { Future { printThreadInfo("fut5") } }

Await.result(fut4, 1.second)  
Await.result(fut5, 1.second)

//Output:
//> fut4 : ForkJoinPool-1-worker-11 = 2
//> fut5 : ForkJoinPool-1-worker-11 = 2

그래서 DynamicVariable이 TLS에서 새로운 스레드로 올바르게 전달되는 문제를 다루지만, 스레드가 이미 만들어져 풀에서 다시 재사용되는 경우, TLS는 복사되지 않고 이전 사용 중 할당된 이전 값을 갖습니다.

ExecutionContext는 모든 스레드 스케줄링을 처리하는데, 그들이 실행되기 전에 TLS를 해당 스레드로 복사하라고 할 수 있을까요? 이 트레이트는 매우 간단합니다. execute는 수정하기 위한 가장 확실한 선택입니다:

/**
 * An `ExecutionContext` is an abstraction over an entity that can execute program logic.
 */
trait ExecutionContext {  

  /** Runs a block of code on this execution context.
   */
  def execute(runnable: Runnable): Unit

  /** Reports that an asynchronous computation failed.
   */
  def reportFailure(t: Throwable): Unit

  /** Prepares for the execution of a task. Returns the prepared
   *  execution context. A valid implementation of `prepare` is one
   *  that simply returns `this`.
   */
  def prepare(): ExecutionContext = this
}

ForkJoinPool 구현을 사용하고 있으므로 수정할 새로운 상속 클래스를 만듭니다. 생성자 파라메터로 DynamicVariable을 보낸다면, 다른 클로저에 대해 염려하지 않아도 됩니다.

import scala.concurrent.forkjoin._

class ForkJoinPoolWithDynamicVariable[T](dynamicVariable: DynamicVariable[T]) extends ForkJoinPool {  
  override def execute(task: Runnable) {
    //need to inject dynamicVariable.value into task
    super.execute(task)
  }
}

그래서 execute는 메인 스레드 내에서 실행되지만, taskFuture의 스레드 풀 내에서 실행됩니다. 어쨌든 dynamicVariableRunnable 내에 주입해야 합니다. dynamicVariable의 값에 클로저를 넣은 후 task를 실행하는 Runnable을 만들어 보겠습니다.

override def execute(task: Runnable) {  
  val copyValue = dynamicVariable.value
  super.execute(new Runnable {
    override def run = {
      dynamicVariable.value = copyValue
      task.run
    }
  })
}

기본적으로, copyValue는 메인 스레드의 dynamicVariable을 읽은 후 run이 thread-pool 스레드 내에서 실행되는 동안 dynamicVariable에 적합한 값을 할당합니다. T가 제네릭이므로, Map을 포함해 어떤 스칼라 클래스든지 될 수 있으므로, 하나의 DynamicVariable은 대부분의 시나리오에 대해 충분히 유연합니다. 우리가 해야 할 일은 새로운 ExecutorService를 사용하는 것인데, 바꾸면:

implicit val executionContext = scala.concurrent.ExecutionContext.Implicits.global  

새로운 클래스를 보면:

val dyn = new DynamicVariable[T](/* default for T */)

implicit val executionContext = scala.concurrent.ExecutionContext.fromExecutorService(  
  new ForkJoinPoolWithDynamicVariable(dyn)
)

가비지 컬렉션에 대해 살짝 알아두어야 할 것이 있습니다. 스레드가 존재하는 한, ThreadPool 변수에 대한 참조를 유지합니다. 만약 thread-pool이 스레드를 재활용하지 않고 스레드가 TLS를 해제하지 않은 채로 풀로 되돌아 간다면, 그러한 객체들은 해제되지 않습니다. 일반적인 경우 이것이 큰 이슈는 아니지만, 더 큰 객체의 경우 사용한 뒤에 명시적으로 해제하거나 허용된다면 WeakReference를 사용하는 것이 현명할 겁니다.

libketama: Consistent Hashing library for memcached clients

우리는 memcached 클라이언트가 키를 서버에 매핑하는 방법을 바꾸기 위해 Ketama를 만들었습니다. 이전에는, 클라이언트가 키를 다음과 같이 서버에 매핑했습니다:

server = serverlist[hash(key) % serverlist.length];  

즉, 풀에 서버를 추가하거나 제거할 때마다, 모든 것이 다른 서버에 해시되어, 전체 캐시를 효과적으로 삭제했습니다.

자주 memcached 풀에 서버를 추가(그리고 때로는 제거)하는데, 이 점이 이 라이브러리 작성의 이유가 됩니다 - memcached 풀이 결코 변경되지 않는다면, 이 글을 읽지 않아도 될 겁니다 :)

Ketama는 일치성 해싱 알고리즘의 구현으로, 모든 키에 대한 전체적인 리매핑을 일으키지 않고 memcached 풀에 서버를 추가하거나 제거할 수 있다는 것을 의미합니다.

동작 방법

  • 서버 목록을 받습니다(예: 1.2.3.4:11211, 5.6.7.8:11211, 9.8.7.6:11211).
  • 각 서버 문자열을 각각의 unsigned int으로 해싱합니다.
  • 개념 상, 이 숫자들은 컨티늄(continum)이라고 하는 원에 놓입니다(0에서 2^32까지의 시계를 상상해보세요).
  • 각각의 숫자는 그것이 해시된 서버에 연결되고, 해시된 숫자에 따라 컨티늄 상의 몇몇 지점에 서버가 놓입니다.
  • 키를 서버에 매핑하기 위해, 키를 단일 unsigned int로 해싱하고, 컨티늄 상에서 가장 큰 숫자를 찾습니다. 그 숫자와 연결된 서버가 그 키에 대한 올바른 서버입니다.

리스트에서 서버를 추가하거나 제거하는 경우, 적은 비율의 키만 다른 서버에 매핑됩니다.

코드

코드의 대부분은 C 라이브러리 (libketama)와 그것을 감싸는 php4 확장입니다. 또한 자바 클라이언트의 클래스도 포함시켰습니다(자바 컬렉션이 라이브러리를 더 쉽게 만듭니다). 네이티브 php 클래스로 래핑된 단일 서버의 memcached 클라이언트를 사용해 다중 서버를 사용할 수 있게 만들어, 해싱 메서드를 ketamafindserver 호출로 대체했습니다(필요하다면 이것을 libmemcache에 쉽게 끼워 넣을 수 있어야 합니다).

libketama on github

현재 10일 가까이 Last.fm의 모든 php 설치본과 자바 서비스의 상용에서 사용하고 있습니다. 데이터 센터 내 웹 서버의 부하를 원활하게 이동시킬 수 있도록 이 라이브러리를 제때에 시간에 맞추어 배포했습니다.

더 읽어볼 거리

Consistent Hashing, a guide & Go libarary

본문: https://medium.com/@sent0hil/consistent-hashing-a-guide-go-implementation-fe3421ac3e8f

Consistent Hashing은 믿을 수 없을 만큼 간단하면서도 매우 강력하지만, 앉아서 직접 해보기 전까지는 그것이 무엇인지 잘 이해하지 못했습니다. Consistent Hashing에 대해 이야기하기 전에, 해결하려고 하는 문제가 무엇인지를 이해해야 합니다:

분산 네트워크 내에서 키를 저장하고 조회할 서버가 어떤 것인지 어떻게 결정해야 할까요? 요구사항은 다음과 같습니다. 모든 노드가 상대적으로 동일한 수의 키를 가져오고, 최소한의 키가 움직이도록 노드를 추가하거나 제거할 수 있어야 합니다.

몇 가지를 가정해보죠: 네트워크 내에는 Chico, Harpo, Groucho 그리고 Zeppo라는 네 개의 서버가 존재합니다. 네 개 서버 모두 동일하지만, 서버로에 대해서 알지 못합니다.

이 예제를 간단하게 하기 위해, 키를 증가하는 정수라고 하겠습니다. 보통 여러분은 체크섬에 대해 키를 실행하여 숫자를 반환합니다. 이 숫자를 얻으면, 노드 수에 맞춰 그 숫자의 모듈로를 취할 수 있습니다. 이는 네트워크가 안정적인 경우(즉, 네트워크를 떠나거나 들어오는 노드가 없는 경우) 놀라울 만큼 잘 동작합니다.

하지만, 만약 하나의 노드, 즉 Harpor가 중지되는 일이 발생하면 어떨까요? 그러면 큰 문제가 발생합니다. 동일한 Hash 함수를 사용하는 경우, 동일한 결과를 갖지만, 모듈로 연산을 적용하면 노드의 개수가 한 개 줄어듦으로 이전과 다른 결과를 얻게 됩니다.

모든 노드의 모든 키 역시 대부분 다시 매핑되어야 하는지 확인해야 합니다. 이는 합리적이지 않습니다. 제대로 동작하고 있는 서버의 키를 다시 매핑해야 하나요? 제 의견에 찬성하시나요? 이제 consistent hashing에 대한 요구 사항에 도달했습니다.

Consistent hashing은 해시 테이블의 크기가 변경되고 consistent hasing이 사용되는 경우, 오직 K/n 개의 키만이 평균적으로 다시 매핑되는 특별한 종류의 hashing입니다. 여기서 K란 키의 개수이며 n은 슬롯의 개수입니다.

출처: https://en.wikipedia.org/wiki/Consistent_hashing

위에서 consistent hashing을 사용했더라면, Harpo의 키만 옮겨지면 됐을 겁니다. 보통 대부분의 포스트에서 이를 단위 서클의 그림을 이용해 그점에 대해서 설명합니다. 그렇게 해보죠:

노드가 단위 서클에 배치되는지는 당분간 제쳐두도록 하겠습니다. 키의 해시 상에 모듈로 함수를 적용하는 대신, 해시를 단위 서클 상에 매핑하겠습니다(말 뿐인 것 같지만 곧 구현하게 될 겁니다). 키가 매핑되는 노드를 결정하기 위해, 노드를 찾을 때까지 단순히 시계 방향으로 진행합니다. 그러므로 1번 키는 Harpo에 저장되고 그로부터 조회되어야 합니다.

만약 Harpo가 중지된다면 어떨까요? 다른 노드로부터 1번 키를 얻거나 조회해야 할 겁니다. 하지만 나머지 키의 매핑이 변하지 않았는지를 유의해야 합니다. 변경된 유일한 키는 Harpo 노드에서 사용되던 것들입니다. 이는 새로운 노드를 추가하는 경우에도 동작합니다. 네트워크에 Gummo를 추가했다고 해보죠. 기존 키의 위치를 변경할 필요가 없습니다.

또한 Consistent hashing은 노드의 크기가 다른 상황도 커버합니다. 여러분이 해야할 것은 가상 노드를 단위 서클에 생성하는 것입니다. 여러분이 선택한 해시 함수에 따라, 가상 노드는 단위 서클 상에 무작위로 위치하도록 만들어질 수 있습니다. 노드가 좀 더 많은 용량을 갖도록 하려면, 더 많은 가상 노드들을 추가해야 합니다. 이런 방법으로 노드가 중지된 경우, 키가 단순히 다음 노드가 아닌 다른 노드에 고르게 분산됩니다.

구현

여기서 좀 더 진행해 Go로 consistent hash 라이브러리를 구현해보겠습니다. 좋은 구현을 찾고 모든 곳에 print 구문을 넣고 코드를 변경할 때까지 그것을 이해하지 못했습니다. 이 구현은 stathat의 구현에 상당히 영감을 받았습니다. 원래의 논문은 구현을 위해 트리를 사용해 호출하지만, 저는 stathat이 한 방법을 더 선호합니다.

인터페이스를 정의해보죠:

// Initializes new distribute network of nodes or a ring.
func NewRing() *Ring  
// Adds node to the ring.
func (r *Ring) AddNode(id string)  
// Removes node from the ring if it exists, else returns 
// ErrNodeNotFound.
func (r *Ring) RemoveNode(id string) error  
// Gets node which is mapped to the key. Return value is identifer
// of the node given in `AddNode`.
func (r *Ring) Get(key string) string  

crc32를 사용해 키의 체크섬을 생성할 겁니다. crc32가 하는 일과 그 방법을 설명하는 것은 이 포스트의 범주를 벗어납니다. 입력이 주어지면, 32 uint가 반환된다고 알고 있으면 됩니다. 이 경우에 입력은 노드의 IP 주소입니다.

그것의 요점은 노드 아이디의 체크섬 결과를 배열에 둔다는 것입니다. 각 키에 대해 체크섬을 실행하고 키가 추가되어야 하는 위치를 정하고 그 위치와 가장 가까운 노드를 반환합니다. 배열의 범위를 넘으면, 첫번째 노드를 반환합니다.

먼저, Ring을 정의해보죠. 이는 Node의 컬렉션입니다.

package consistenthash  
// Ring is a network of distributed nodes.
type Ring struct {  
  Nodes Nodes
}
// Nodes is an array of nodes.
type Nodes []Node  
// Node is a single entity in a ring.
type Node struct {  
 Id     string
 HashId uint32
}

다음으로, RingNode를 위한 초기화 함수를 작성해보죠:

package consistenthash  
func NewRing() *Ring {  
  return &Ring{Nodes : Nodes{}}
}
func NewNode(id string) *Node{  
  return &Node{
    Id        : id,
    hashedKey : crc32.Checksum([]byte(id)),
  }
}

이제 드디어 AddNode를 채워넣을 준비가 됐습니다:

func (r *Ring) AddNode(id string) {  
  node := NewNode(id)  
  r.Nodes = append(r.Nodes, node)
  sort.Sort(r.Nodes)
}

sort.Sort를 실행할까요? 단위 서클로 돌아가서 봐야 합니다. 정확하게 단위 서클을 어떻게 구현할까요? 한 가지 방법은 배열의 엔트리 내에 첫번째 아이템을 가리키는 마지막 아이템을 두는 것입니다. 이를 위해 연결 리스트를 사용할 수 있지만, 그것이 불필요한 이유에 대해서 곧 충분히 알게 될 겁니다.

지금까지 한 것을 실행하면, Nodesort.Sort 인터페이스를 구현하지 않았기 때문에 Go 컴파일러가 여러분에게 무언가를 던질 겁니다. 쉽게 해결할 수 있습니다:

package consistenthash  
func (n Nodes) Len() int           { return len(n) }  
func (n Nodes) Less(i, j int) bool { return n[i].HashId < n[j].HashId }  
func (n Nodes) Swap(i, j int)      { n[i], n[j] = n[j], n[i] }  

이 모든 것의 요점인 Get을 구현해보겠습니다:

func (r *Ring) Get(key string) string {  
  searchfn := func(i int) bool {
    return r.Nodes[i].HashId >= crc32.Checksum([]byte(key))
  }
  i := sort.Search(r.Nodes.Len(), searchfn)
  if i >= r.Nodes.Len() {
    i = 0
  }
  return r.Nodes[i].Id
}

sort.Search는 바이너리 검색을 사용해 배열 내에서 기존의 노드를 찾아냅니다. 존재하지 않는 경우 노드가 추가되어야 할 위치를 반환합니다. 노드 체크섬이 마지막 노드보다 크다면, 첫번째 노드에 그것을 추가합니다. 그리고 그게 답니다.

코드의 나머지 부분을 보려면 여기에 약간의 테스트와 함께 오픈 소스화되어 있습니다.

처음에 consistent hashing이 믿을 수 없을 만큼 간단하고도 강력하다고 말한 것을 기억하나요? 아직 저를 믿나요? consistent hashing이 분산 시스템에 관해 좀 아는 Akamai의 논문에서 처음 도입되었다는 것을 알아둬야 합니다. consistent hashing의 향상된 버전은 분산 해시 테이블인 Chord 알고리즘에서 사용됩니다(이전 버전에서는 Chord가 Amazon DynamoDB보다 못하다고 하는데, 이는 잘못된 것입니다).

저는 여전히 chord를 읽고 이해하는 과정에 있으며, 구현하는 것은 말할 것도 없고, 일단 완료되면 포스트할 겁니다. 저는 포스트를 쓰는 것은 물론이거니와, consistent hashing에 대해 배우고, 구현하는 것이 매우 재미있습니다. 여러분은 그것을 배우거나 구현하기를 바랍니다. 오류나 더 좋다고 말할 수 있는 무언가를 찾는다면, @sent-hil로 트윗해주시기 바랍니다.

Redis Keys in RAM

원문: https://redislabs.com/blog/redis-keys-in-ram/

Dr. Seuss의 "Green Eggs and Ham"을 각색. 텍스트에 대한 링크. 그림은 Dr. Seuss에게 저작권이 있음.

I am San. 나는 San이야.
I am San. 나는 San이야.
San I am. San I am.

That San-I-am! 바로 San-I-am이라고!
That San-I-am! 바로 San-I-am이라고!
I do not like that San-I-am! 난 San-I-am을 좋아하지 않아!

Do you like Redis keys in RAM? 너는 RAM 안에 있는 Redis 키들을 좋아하니?

I do not like them San-I-am 난 그것을 좋아하지 않아, San-I-am
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아

Would you like them large [1] or small? 그들이 큰게 좋아 작은게 좋아?

I would not like them large or small. 그들이 크거나 작은 걸 좋아하지 않아.
I would not like them not at all. 난 그들을 전혀 좋아하지 않을거야.
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not like them San-I-am. 나는 그들을 좋아하지 않아 San-I-am.

Would you like them as a String? 그것들이 문자열이길 바래?
Would you serialize everything? 모든 것을 직렬화하길 바래?

I do not like them as a String. 문자열을 좋아하지 않아.
I do not like to serialize things. 직렬화하는 것을 좋아하지 않아.
I do not like them large or small. 그들이 크든 작든 좋아하지 않아.
I do not like them not at all. 나는 그들을 전혀 좋아하지 않아.
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not like them San-I-am. 나는 그들을 좋아하지 않아 San-I-am.

Would you like them in a Hash? 그들이 Hash 안에 있길 바라니?
Would you like a Hash as cache? cache 같은 Hash를 바라니?

Not in a Hash. Not as a cache. Hash 안에 있는 걸 바라지 않아. Cache도 바라지 않아.
Not as a String. No serialized, no anything. 문자열 같은 것도. 직렬화도, 어떤 것도 원치 않아.
I do not want them large or small. 난 그들이 크거나 작길 바라지 않아.
I do not want them, not at all. 난 그들을 전혀 원하지 않아.
I do not want Redis keys in RAM. 난 RAM 안에 있는 Redis 키를 원하지 않아.
I do not want them, San-I-am. 난 그들을 원하지 않아, San-I-am.

Would you want them as a List instead? 그러면 대신 List이길 바라니?
Do you want to access tail, body and head? 테일, 본문 그리고 헤드에 액세스하길 원하니?

Not as a List. Not as a Hash. 리스트를 원하지 않아. Hash를 원하지 않아.
Not as a String. Not as a cache. 문자열을 원하지 않아. cache를 원하지 않아.
Small or large I will have naught. 작든 크든 0을 가질 거야.
Goodbye San-I-am and thanks a lot. 안녕 San-I-am 그리고 고마워.

Would you? Could you? As a Set? Set는 어때? 해볼래? 해볼테야?
Get the difference! Store a union! Or just intersect… 차를 구해봐! 합집합을 구해봐! 아니면 교집합을...

I would not, could not, as a Set. 아니 해보고 싶지 않아

You may like them. 그들을 좋아할 수도 있어
You'll see for sure. 확실히 보게 될 거야.
You may like Sorted Sets by score? 스코어에 의한 Sorted Sets를 좋아할지도 몰라.

I would not, could not by a score. 스코어로 하고 싶지도, 할 수도 없어.
No more Sets! I say no more! 더 이상의 Sets는 없어! 더 이상 말하고 싶지 않아!
I do not like them as a List 난 List 같은 것도 좋아하지 않아
Stop this now – I do insist. 이제 그만둬. 분명히 말할게.
I do not like them as String or Hash 문자열이든 Hash든 좋아하지 않아
I do not like an in-memory database or cache. in-memory 데이터베이스든 cache든 좋아하지 않아.
I do not want Redis keys in RAM. RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not want them, San-I-am. 그들을 좋아하지 않아, San-I-am.

You do not like them. So you say. 넌 그들을 좋아하지 않는구나. 너가 말한대로.
http://try.redis.io! Try them! And you may. http://try.redis.io! 한 번 해봐! 좋아하질도 모른다.
Try them and you may, I say. 한 번 해봐, 내가 말하는데로 좋아할거야.

San! If you will let me be, San! 너가 날 놓아준다고 약속하다면,
I will try them. You will see. 한 번 해볼게. 두고봐.

Say! I like Redis keys in RAM! 우와! 난 RAM 안에 있는 Redis 키들이 좋아!
I do! I like them, San-I-am! 정말이야! 그들이 좋아, San-I-am!
So I will have them as a String. 그러니까 문자열을 가질거야.
And as a Hash, a List or anything. 그리고 Hash, List 어떤 것이든지.
And as a Set – both unordered and an ordered one. 그리고 Set, 정렬되지 않은 것이든 정렬된 것이든.
Say! Data structures are so much FUN! 우와! 데이터 구조는 정말 즐거워!

I do so like Redis keys in RAM 난 RAM 안에 Redis 키들이 정말 좋아
Thank you! Grazie, San-I-am 고마워! 고마워요, San-I-am!


후기 노트

저는 암스테르담에서 열린 Percona Live Europe 2015의 "Use Redis in Odd and Unusual Ways"라는 주제의 일부분으로 위와 같은 내용을 발표했습니다. 주요 주제는 MySQL로 이루어진 반면, 컨퍼런스의 프로그램에 Redis에 관한 내용은 4개 세션 밖에 없었습니다. 제 발표를 제외하고 말이죠. Redis 옹호자가 되는 것, 그것이 아마도 내가 바라는 최선의 방법일 것이나, 발표자로써 그것은 도전이었습니다. Redis에 대한 경험을 지닌 청중 뿐만 아니라 NoSQL에 대한 경험을 지닌 청중과 관련된 대화를 어떻게 준비해야 할까?

그래서 결론은 Redis와 대한 기초 주제와 고급 주제를 섞는 것이었고 Redis 베테랑 뿐만 아니라 Redis가 처음인 사람에게 도움이 되길 바랬습니다. DR.ediseuss라는 모티브는 약간의 배경에 가치를 둘 만 합니다. 나 이전의 많은 사람들이 그랬듯이, 내가 빨간 벽돌이 깔린 길에 첫발을 내디뎠을 때처럼, 무언가 API의 네이밍 스키마에 현혹되었습니다. [2] 모든 것을 흡수하면서, 나는 그 모든 것에 대한 첫 단계였던 작은 "시"를 생각해냈습니다. 다음은 여러분의 즐거운을 위한, "Dr. Seuss Reads Redis" 책의 첫번째 (그리고 유일한) 페이지입니다:

This is my friend
His name is ZADD
ZADD's a lad
Who's always SADD

It's really bad
that ZADD is SADD
I don't know why
And that makes me SCARD

I hope that ZADD
Will be someday glad
And that he'll get over
This stupid PFADD

문제가 있나요? 열렬히 환호해 주실건가요? 이메일이나 트윗하세요. 언제나 환영합니다 🙂


FN#1 Redis의 키는 512MB까지 가능하며 바이너리에 안전합니다. Simple string 값은 512MB까지 가능하며 역시 바이너리에 안전합니다. 다른 데이터 구조체들은 2^32개의 요소, 512MB까지 가능합니다.

FN#2 시작하는 이들을 위한 격려의 메모 - 금방 완벽하게 자리를 잡을 것이고 처음에 무엇때문에 그렇게 현혹되었는지를 염려하게 될 것입니다 🙂