devcken.io

Thoughts, stories and ideas.

자바 성능 튜닝: 자바 성능 향상을 위한 완벽 가이드란 책을 보고...

'...란 책을 보고'라는 제목을 붙였지만, 사실 24쪽까지 읽은게 전부다.

난 이 책을 사서 읽기 위해 34,000원이라는 돈을 지불했다. 좋은 책을 읽기 위한 34,000원이라는 돈은 사실 푼돈에 불과하다. 30,000원도 안되는 좋은 책들이 이 세상에는 널렸다.

우리 나라의 IT 서적들은 크게 4가지 종류로 나뉜다. 첫째, 한국인 저자가 직접 썼고 내용도 좋은 책. 둘째, 한국인 저자가 썼으나 좋지 못한 책. 셋째, 외쿡 살람이 쓴 좋은 책. 넷째, 외쿡 살람이 썼으나 좋지 못한 책.

난 외쿡 살람이 쓴 좋은 책을 가장 선호한다. 물론 한국인 저자가 쓴 책 중에도 좋은 책들이 많다. 그 중에서도 조영호님이 쓴 객체 지향의 사실과 오해라는 책은 내가 읽은 어떤 OOP 책 중에서도 최고다. 아무튼, 내가 외쿡 살람이 쓴 좋은 책을 선호하는 이유는 이 포스트에서 중요한 내용은 아니므로 넘어가자.

거꾸로 내가 제일 싫어하는 책은? 한국 살람이 쓴 안좋은 책? 아니다. 안 좋은 책을 쓰는 이유는 많을 것이다. 문장 능력 자체가 떨어진다던가, 책이 다루는 주제에 대한 지식이 책을 쓸만큼 갖춰져 있지 않다던가... 뭐 여러 가지 이유가 있을텐데, 물론 저러한 이유들도 납득하기 쉽지 않다. 허나 내가 제일 싫어하는 책은, '외쿡 살람이 쓴 좋은 책을 한쿡 살람이 이상하게 번역한 책'이다.

'자바 성능 튜닝: 자바 성능 향상을 위한 완벽 가이드'란 책이 딱 그런 책이다. 살때는 몰랐는데 이 책의 제목이 이젠 마음에 안든다. 원제는 Java Performance: The definitive guide다. 자바 성능 완벽 가이드라고 하는게 맞다. 왜냐하면 긴 이름이 마음에 안들기도 하지만, 책의 내용 상 튜닝에만 초점을 맞춘게 아니라, 중점은 성능에 있다. 이는 책에서도 거듭 강조한 부분이고 역자가 제대로 변역한 부분에도 분명 나와있다. 그러므로, 원제처럼 자바 성능이라고 하는 것이 맞다.

내가 이 책을 받아들고 19페이지를 읽을 때쯤 원서를 다운로드 받았다. 이유는 딱 하나.

이게 무슨 뜻이야?

비판하는 몇 가지 증거를 제시하겠다.

이 번역서의 21페이지, 첫 단락을 보면 이렇게 나와있다.

여기서 세 번째 위험 요소는 테스트의 입력 값 범위다. 임의의 수를 선택하는 데 코드를 사용하는 방법을 나타낼 필요는 없다. 이와 같은 경우 (음수일 때) 테스트 중인 메서드에 대한 호출 대신 바로 예외 처리를 한다. 가장 큰 피보나치 수는 두 배로 나타날 수 있으므로 1476보다 큰 파라미터 값이 들어오면 예외 처리한다.

원서의 내용은 다음과 같다.

The third pitfall here is the input range of the test: selecting arbitrary random values isn’t necessarily representative of how the code will be used. In this case, an exception will be immediately thrown on half of the calls to the method under test (anything with a negative value). An exception will also be thrown anytime the input parameter is greater than 1476, since that is the largest Fibonacci number that can be represented in a double.

이 단락을 이해하려면 맥락을 이해해야 한다. 두 가지 코드를 볼 것이다.

먼저,

for (int i = 0; i < nLoops; i++) {  
  l = fibImpl1(random.nextInteger());
}

여기서 fibImpl1 메서드는 피보나치 수를 구하는 함수로 한 개의 정수형 인자를 받는다. 그런데 컴파일러의 똑똑함을 극복(?)하기 위해서 균일한 값이 아닌 랜덤 값을 입력시키고자 난수 발생기로 정수를 가져와 입력하는 코드다.

그리고,

int[] input = new int[nLoops];  
for (int i = 0; i < nLoops; i++) {  
  input[i] = random.nextInt();
}
long then = System.currentTimeMillis();  
for (int i = 0; i < nLoops; i++) {  
  try {
    l = fibImpl1(input[i]);
  } catch (IllegalArgumentException iae) {
  }
}
long now = System.currentTimeMillis();  

위 코드는 첫번째 코드를 실제적인 성능 측정을 위해 개선한 것이다. 랜덤 값 발생 비용이 측정에 포함되므로 이를 제외하기 위한 코드 분리를 추가한 것이다.

원서의 문장에서 selecting arbitary random values라고 말한 부분이 바로 이것을 두고 말한 것이다. 즉, 해당 메서드를 실제 사용할 때 랜덤 값 생성은 포함되지 않으므로, 입력 값의 범위를 정의해주어야 한다는 것이 이 단락 그리고 절의 핵심이다.

그래서, 임의의 수를 선택하는 데 코드를 사용하는 방법을 나타낼 필요는 없다.라고 번역한 것은 문맥을 고려하지 않고 그냥 번역한 것이다. 이는 랜덤 값 선택이 반드시 코드 사용 방법을 나타내는 것은 아니다.라고 변역되거나 좀 더 의역하여 랜덤 값 선택은 해당 메서드의 내용이 아니다.라고 변역해야 한다.

그 다음 문장 이와 같은 경우 (음수일 때) 테스트 중인 메서드에 대한 호출 대신 바로 예외 처리를 한다.은 심지어 내용을 완전히 왜곡한 것도 모자라 수동태를 능동태로 옮겨버렸다. 원서의 문장 In this case, an exception will be immediately thrown on half of the calls to the method under test (anything with a negative value).에서 핵심은 on half of the calls다. 이 문장에서 말하고자 하는 내용은 테스트 대상 메서드에 대한 호출 중 절반(음수 값을 가진 모든 호출)에 대해 예외가 즉시 발생한다.는 것이다. 즉, random.nextInt() 메서드가 음수 값을 발생시킬 확률이 50% 정도는 될 것이므로 호출의 절반이 예외를 발생시킬 것이라고 말하고 있는 것이다.

위 단락에서 마지막 문장 가장 큰 피보나치 수는 두 배로 나타날 수 있으므로 1476보다 큰 파라미터 값이 들어오면 예외 처리한다.는 이 책의 정수(?)를 보여준다. 이 문장을 설명하려면 코드 하나를 더 봐야 한다(코드의 내용은 일단 무시하자).

public double fibImplSlow(int n) {  
  if (n < 0) throw new IllegalArgumentException("Must be > 0"); 
  if (n > 1476) throw new ArithmeticException("Must be < 1476");
  return verySlowImpl(n);
}

나는 이러한 번역이 왜 나왔는지를 역으로 추적해봄으로써 올바른 번역을 소개하고자 한다.

첫번째로, '가장 큰 피보나치 수는 두 배로 나타날 수 있다'라는 문장이 도대체 무엇을 의미하는걸까? 이것은 원서의 문장 중 뒤에 that절을 봐야한다. 대충 직역하자면, '그것이 가장 큰 피보나치 숫자이고, double로 표현될 수 있다' 정도일텐데, 여기서 역자는 a double를 두배라고 번역했다. 그렇게 번역하려면 원래의 문장에서 in a doubleto be doubled가 되어야 한다. 관사 a는 도대체 어디로 날려먹은걸까? 여기서 a double은 우리가 흔히(?) 얘기하는 double 변수, 즉 배정밀도 변수를 말한다. 갑자기 왠 double? 일단 다음으로 넘어가자.

두번째로, 1476이다. 1476이라는 숫자는 위 코드에서 경계값으로 예외 조건이다. 내 생각에 저자는 이 1476이라는 숫자가 무엇을 의미하는지 몰랐던 것 같다. 만약 알았다면 첫번째 내용을 이해했어야 한다. 만약 피보나치 메서드에 1476이 대입된다면 코드의 내용 상 아마도 그에 대한 피보나치 값이 나올 것이라는 것을 추정할 수 있다. 그렇다면 1477은 왜 안되는 걸까?

피보나치 수는 입력 값이 커질 수록 급격하게 증가한다. 내 기억으로 long이나 double이 아닌 int로 구현할 경우 입력값 한계가 300도 안됐던걸로 기억한다. 1476가 입력됐을 때 double 변수에 담을 수 있는 최대의 피보나치 수가 결과로 나온다. 즉, 1477이 입력되면 overflow가 발생할 것이고 결과는 음수가 나온다. 이를 사전에 막기위한 조치가 바로 if (n > 1476)이다.

이 책의 구현 방식으로는 사실 1476은 고사하고 50만 입력해도 엄청 오래 걸린다. 이 책의 구현 방식은 피보나치 수에 대한 알고리즘을 그대로 구현한 것으로 꼬리 재귀를 만족시키지 못한다. 예제에서 50을 입력하도록 되어 있는데, 이 부분이 원서의 수준을 의심케 하는 대목이다. 19페이지를 보면 내가 의심하는 이유를 알 것이다. 내가 직접 해본 바로는 책에서 말하는 내용을 증명할 수 없었다.

그러므로 원래의 문장 An exception will also be thrown anytime the input parameter is greater than 1476, since that is the largest Fibonacci number that can be represented in a double.또한, 1476이 한 개의 double 변수로 나타낼 수 있는 가장 큰 피보나치 수를 만들어내므로 입력 파라메터가 그 수보다 크면 예외가 발생한다.라고 번역되어야 한다.

다음은 내가 결국 이 책을 덮게 만든 문장(24페이지)이다.

벤치마크 내에서 전반적으로 시간 차이가 나서 루프를 많이 도는 동안에는 몇 초 후에 측정되지만 각 반복 직후에는 나노초 후에 측정되곤 한다.

이 문장이 도저히 이해가 안가 다시 원서를 봤다.

The overall time difference in a benchmark such as the one discussed here may be measured in seconds for a large number of loops, but the per-iteration difference is often measured in nano‐ seconds.

그 뒷문장은 다음과 같다(원서와 번역서의 문장을 같이 보여주겠다).

Yes, nanoseconds add up, and “death by 1,000 cuts” is a frequent performance issue.(이 나노초들이 더해져서 "가랑비에 옷 젖게 되는" 경우는 흔한 성능상의 이슈다)

뒷 문장은 그런대로 번역이 되었다. 그런데... 앞 문장을 왜 저렇게 번역했을까?

이 문장에서 의도하는 바를 대충 번역하자면, 여기서 거론되고 있는 벤치마크처럼 하나의 벤치마크 상의 전체 시간 차는 많은 회수의 루프의 경우에 초단위로 측정되지만, 이터레이션 당(즉 루프 한번) 시간 차는 대게 나노 초로 측정될 것이다. 정도가 될 것 같다. 그래야 뒷문장에서 말하는 나노초의 합산과 death by 1,000 cuts로 인한 상습적인 성능 이슈가 설명된다.

번역서의 의미로는 도저히 의미 파악도 안되고 문제의 문장과 그 뒷문장이 연결되지도 않는다.

이들 외에도 지적할 부분들이 있지만, 더해봐야 시간 낭비다(이미...).

내가 이러한 오역서들에 짜증이 나는 이유는 잘못된 정보 전달이 제일 크다. 책을 볼 때 의심 내지 비평을 하면서 보는 자세는 당연히 필요하지만, 이 책은 정보서다. 정보서라는 것은 정확한 정보만을 명확하게 전달해야 한다. 물론 환경이나 여러 가지 이유로 똑같이 재현이 안되거나 일부 틀린 내용이 있을 수는 있다. 허나 이 번역서에서 보여준 실수는 실수가 아니다. 역자는 그 내용이 어떤 내용인지도 모르고 책을 번역했음이 분명하다.

나는 내가 책 읽는 속도가 느리다고 생각했었는데(사실 좀 답답하긴 하다), 오늘에서야 그 의심을 좀 거둘 수 있을거 같다. 이 책의 24페이지까지 읽는데 무려 1시간 20분이 걸렸는데, 문장을 보는 내내 턱턱 막히고 그 의미를 다시 찾아보고 원서와 비교하다보니 그랬다. 이는 정보서적으로써는 0점이다.

생각보다 엄청 긴 글이 됐는데, 역자에게는 죄송스러운 마음이 든다. 뭐 사실 이 포스트를 얼마나 많은 사람이 보겠냐마는... 책 한권을 번역한다는게(이 책이 무려 500페이지가 넘는다) 엄청난 일이라는 것을 나도 대충 알기에 이렇게 비판하는 것이 그리 속시원하지만은 않다. 하지만, 번역서는 원서의 명성은 둘째치고 있는 그대로의 정보를 전달하기 위해 정성을 들여야 한다는 점은 포기하지 못하겠다.

혹자가 이런 말을 할까 하여 한마디 덧붙인다.

번역이 좀 틀릴 수도 있는거고, 번역한 거 자체도 고마워해야 하는거 아닌가요?

예전에 웹에 어떤 문서를 번역한 내용이 틀렸다라는 것을 알려주면서(상대방에게는 지적으로 들렸을 수도? 아니 사실 지적 맞다) 들은 말이다. 그 때는 그냥 별거 아니라 생각하고 넘겼는데, 지금은 제대로 말해주고 싶다.

번역서라고 해도 원저자의 의도를 훼손할 권리는 없는 것이고, 돈을 받고 판매를 하는 책에 대한 의무가 있는 겁니다. 그리고 설령 어떤 내용을 번역하여 무료로 웹에 올렸다고 해도 오역에 대한 비판 내지 충고는 들을 자세가 되어 있어야 합니다.

Algorithm 강의를 듣다가...

최근 Coursera에서 Data structures and algorithms 라는 강의를 듣기 시작했는데, 첫주 과제의 문제를 풀다가 이런 생각을 하게 됐다.

큰 데이터셋을 테스트하라고? 귀찮은데 그냥 제공해주면 어디 덧나니? 하아...

그 뒤 설명에는 표준 입력을 받는 방식으로 테스트하면 큰 데이터셋을 테스트하기 어려우니 대신 파일을 받아서 파일의 내용을 이용하라고 되어 있었다.

귀찮... 아 일단 다음으로 넘어가자.

문제 지시 사항이 여러 절로 되어 있는데 다음 절로 넘어가자마자 이런 글이 보였다.

You are probably wondering why we did not provide you with the 5th out of 17 test datasets that brought down your program.

그러니까, '프로그램이 제대로 실행되지 않을텐데 왜 그 데이터를 제공하지 않는지 궁금해 할지도 모르겠다'라는거다. 어?! 뭐야, 이 자식! 날 꿰뚫어 보는 거 같아 기분이 나빴다... 그런데 뒤이어서,

The reason is that nobody will provide you with the test cases in real life!

아... 딱 한마디 문장으로... 더 정확히는 딱 두 단어 real life로 날 반성하게 만들었다. 그래, 실제 삶에서 나한테 테스트 데이터 따위를 친절하게 넘겨주는 사람은 없었지...

앞으로는 테스트 작성은 물론 테스트 데이터를 만들어내는 일로 귀차니즘을 느낀다면 이 포스트를 다시 꺼내보리라.

Parser Combinator

원문: Parser Combinator

함수형 프로그래밍의 훌륭한 많은 아이디어들이 객체 지향 프로그래밍에 적용됩니다(알다시피, 함수 역시 객체죠). 특히, 파서 콤비네이터는 함수형 프로그래밍 커뮤니티에서 오랜 역사를 지닌 기술입니다.

이것은 하나의 포스트일 뿐, 그러한 아이디어에 대한 정식 참고 자료를 제공하지는 않습니다. 그것이 수십년도 더 됐다는 점과, Phil Wadler, Erik Meijer가 이 분야에서 중요한 일을 해왔다고 말할 수 있습니다. 저 자신은 Martin Odersky의 스칼라 튜토리얼을 보고 깊은 영감을 얻었습니다.

스몰토크로 간단한 파서 콤비네이터 라이브러리를 만든적이 있는데, 매우 잘 동작하는 것 같습니다. 저는 그에 대한 내용을 OO 관점으로 여기에 기록하는 것이 좋을거라고 생각했습니다.

그러면, 파서 콤비네이터라는 것이 정확히 무엇일까요? 기본적인 개념은 BNF의 연산자들(또는 해당 문제에 대한 정규표현식)을 문법의 프로덕션을 나타내는 객체들에 대해 연산하는 메서드로 보는 것입니다. 그러한 각각의 객체가 특정 프로덕션으로 지정되는 언어를 수용하는 파서입니다. 해당 메서드 호출의 결과 또한 그러한 파서입니다. 그러한 연산은 다소 난해한 기술적인 이유로 (그리고 읽기만 하는 컴퓨터를 잘 모르는 이들이 무서워하다록 하기 위해) 콤비네이터라 불립니다.

이를 구체적으로 설명하기 위해, 식별자에 대한 명확한 표준 규칙을 살펴보도록 하겠습니다:

id -> letter(letter|digit)*  

스몰토크로 만든 제 콤비네이터 라이브러리를 사용하자면, 하나는 CombinatorialParser의 하위 클래스를 정의하고, 그 안에 다음과 같이 작성합니다

id := self letter, [(self letter | [self digit]) star]  

여기서, letter란 단일 문자를 받는 파서이고, digit이란, 단일 숫자를 받는 파서입니다. 둘 다 self(스몰토크로 프로그래밍을 해보지 않은 불운한 이들의 경우 this라고 생각하면 됩니다)에 대한 메서드 호출로 얻어집니다. 하위 표현식인 self letter|[self digit]은 숫자가 허용되는 인자와 함께 문자를 허용하는 파서에 | 메서드를 호출합니다(잠시 대괄호은 잊기 바랍니다). 그 결과는 문자 또는 숫자를 받는 파서가 됩니다.

여담: 아니 버지니아, 스몰토크에는 연산자 오버로딩이 없어(역자 주: 아마도 No, Virginia, There is No Santa Claus의 내용을 빗대어 표현한 것이 아닐까요?). 스몰토크는 단순히 알파벳과 숫자가 아닌 문자를 사용하는 메서드 이름을 허용합니다. 이런 것들은 항상 이항 연산이며 모두 동일한 고정 우선 순위를 갖습니다. 어떻게 하면 그렇게 단순할까요? 그것을 미니멀리즘이라고 부르며, 모든 이들을 위한 것은 아닙니다. Mies van der Rohe나 Rococo처럼 말이죠.

제가 강조한 유일한 세부 내용은 대괄호입니다. 대괄호는 클로저를 나타내므로, [self digit]는 적용 시, 숫자를 허용하는 파서를 만들어냅니다. 이렇게 한 이유가 뭘까요? 문법 규칙은 종종 서로 회귀할 수 있기 때문입니다. 프로덕션 A가 만약 프로덕션 B 내에서 사용되고 그 반대의 경우도 있다면, 그들 중 하나(예를 들어 A)는 다른 프로덕션(예를 들어 B)이 아직 정의되지 않고 아직 정의되지 않은 시점에 먼저 정의되어야 합니다. 하나의 클로저 내에서 참조를 다른 프로덕션으로 래핑하는 것은 평가를 지연시키고 이러한 문제가 발생합니다. 하스켈과 같은 lazy한 언어에서 이 점은 문제(중요한 한 가지 원인)가 되지 않습니다. 하스켈은 DSL 정의에 매우 좋습니다. 하지만, 스몰토크의 클로저 문법은 매우 가볍습니다(대부분의 함수형 언어의 람다보다도 더 말이죠)! 그러니 이것은 큰 문제가 아닙니다. 그리고 스몰토크의 이항 메서드와 후위 단항 메서드는 전반적으로 매우 유쾌한 결과를 가져다 줍니다.

그러면 해당 결과에 star 메서드를 호출하겠습니다

(self letter | [self digit]) star

위 예제는 문자 또는 숫자가 0개 이상 나오는 경우를 허용하는 파서를 만듭니다.

더 많은 이들이 이해하는 문법으로 표현하자면 다음과 같을 겁니다:

(1) letter().or(new DelayedParser(){ public Parser value(){ return digit();} }).star()

자바가 클로저를 가지고 있다면 다음과 같을 겁니다:

(2) letter().or({=> digit()}).star()

이것이 더 나아보이지만, 어떤 방법이든, 실행 가능한 문법 작성의 목적은 별로 중요하지 않습니다. 그럼에도 불구하고, 대부분의 사람들이 (1)을 더 선호하고, 나머지 대다수는 "기괴한" 스몰토크 문법에 비해 (2)를 선호하는 것으로 보입니다. 어떤 어두운 면이 사람의 마음 속에 있는지 누가 알까요.

letter에서 호출한 메서드에 파서를 인자로 전달했습니다. "," 메서드는 (BNF에서는 암시적인) 시퀀싱(연결) 콤비네이터입니다. 그것을 먼저 수신자(자바로 치면 타겟)의 언어를 받아들이고 인자에 대한 언어를 받는 파서를 반환합니다. 이 경우, 결과는 우리가 예상한대로 뒤에 0개 이상의 문자 또는 숫자가 오는 단일 문자를 허용한다는 것을 의미합니다. 마지막으로, 이 결과를 식별자에 대한 프로덕션을 나타내는 아이디에 할당합니다. 다른 규칙들은 아이디를 접근자(즉, self id)를 호출하여 사용할 수 있습니다.

또한 이 예제는 구문 분석에 대한 접근 방법이 렉서(lexer)와 파서 모두를 다루고 있음을 보여줍니다.

렉싱(lexing)과 파싱 간에 구분이 안되는 부분은 약간 문제입니다. 전통적으로, 입력을 토크나이즈하기 위해 렉서에 의존해왔습니다. 그렇게 함으로써, (공백 문자를 중요하게 여기는 언어들은 제외하고) 공백 문자와 주석을 없앱니다. 이것은 새로운 연산자인 tokenFor:를 정의해 쉽게 처리되는데, 이 연산자는 파서 p를 받고 앞에 나오는 공백 문자와 주석을 건너뛴 후 p가 허용한 것은 무엇이든 허용하는 새로운 파서를 반환합니다. 또한 이 파서는 해당 결과에 소스의 시작 인덱스와 끝 인덱스를 붙일 수 있어, 파서를 IDE와 통합할 때 매우 편리합니다. 더 높은 수준의 문법 프로덕션의 관점에서, 그렇게 토크나이즈된 결과를 만들어내는 프로덕션 식별자 참조는 유용합니다:

identifier := self tokenFor: self id.  

이런 과정을 언어 속 모든 토큰에 대해 자연스럽게 진행한 후, 전통적인 BNF에서 그랬던 것처럼, 공백 문자 또는 주석과는 관계없이 구문적인 문법을 정의하게 됩니다. 다음 예제는 스몰토크의 return 문에 대한 규칙입니다

returnStatement := self hat, [self expression].  

좋습니다. 문법을 써내려가는 것으로 파서를 멋지게 정의할 수 있습니다. 하지만, 하나의 언어를 받아들이는 것만으로는 그렇게 유용하지 않습니다. 일반적으로, AST를 결과로 만들어내야 합니다. 이를 해결하기 위해, 새로운 연산자인 wrapper:를 도입했습니다. 이 연산자의 결과는 동일한 언어를 수신자로 허용하는 파서입니다. 하지만, 구문 분석 과정에서 만들어지는 결과는 다릅니다. 구문 분석된 토큰을 반환하는 대신, 유일한 파라메터로 받는 클로저를 사용해 이러한 토큰들을 처리합니다. 클로저는 해당 파서의 출력을 입력으로 받고, 어떤 결과(일반적으로 추상 구문 트리)를 산출합니다.

returnStatement := self hat,  [self expression]

     wrapper:[:r :e  | ReturnStatAST new expr:e; start: r start; end: e end].

문법 프로덕션은 구분 라인의 AST 생성으로 명확하게 구분됩니다. 하지만, 저는 문법을 원래 그대로의 상태로 두는 것을 더 좋아합니다. 그것은 쉽습니다. 모든 AST 생성 코드를 하위 클래스에 두는 것이죠. 이 때 문법 프로덕션의 접근자가 재정의됩니다. 그래서:

returnStatement  
^super returnStatement
    wrapper:[:r :e  | ReturnStatAST new expr:e; start: r start; end: e end].    

예를 들어, 동일한 언어를 구문 분석하고 자신의 AST를 각각 허용하는 다른 백엔드에 전달하고자 하는 경우에 유용합니다. 또는 구문 컬러링과 같은 다른 목적을 위해 파서를 사용해야 하지만 문법을 공유하고자 하는 경우에도 말이죠. 이러한 방식의 또 다른 좋은 점은 언어의 확장을 매우 분명하게 뽑아낼 수 있다는 것입니다(특히 믹스인을 사용하는 경우). 그것이 일반적인 범용 언어에 DSL을 임베드하는 이점 중 하나입니다(여러분의 DSL이 호스트 언어의 모든 기능을 상속할테니까요). 이런 경우, DSL은 상속을 상속받습니다.

그럼 안좋은 점은 뭘까요? 음, 구문 분석에 대한 좀 더 효율적인 접근 방식을 상상해볼 수 있습니다. 스몰토크에서, 보통 한번에 하나의 메서드를 구문 분석하니, 메서드들이 작아지는 경향이 있습니다. 비록 그렇게 빠르지는 않은 Squeak를 사용하여 구문 컬러링을 하기 위해 모든 키스트로크마다 메서드를 구문 분석하긴 하지만, 완전히 만족스럽습니다. 큰 메서드의 경우, 지연이 눈에 띌 만큼 클 수도 있습니다. 그러나 성능 향상을 위한 튜닝 방법이 존재합니다. 우리가 가진 방법 말이죠...

다른 문제는 다음과 같은, 좌측 재귀입니다:

expr -> expr + expr | expr * expr | id  

이러한 경우 문법을 리팩터링해야만 합니다. 저는 이것이 큰 문제가 아니라고 생각하지만, 원칙 상 파서는 문제 해결을 위해 자신을 동적으로 리팩터링할 수 있습니다. 이것이 스몰토크에서는 상대적으로 쉽게 할 수 있는 것 중 하나이며, 다른 언어에서는 더 어렵게 여겨지는 것입니다.

요약하자면, 파서 콤비네이터는 정말 좋습니다. 스몰토크에서 멋지게 동작하죠. 그것을 구현하고 사용하면서 즐거웠습니다. 가장 중요한 것은, 객체 지향과 함수형 프로그래밍이 시너지를 내는 방식에 대한 아주 훌륭한 예제라는 것입니다. 좀 더 알아보려면, 주로 하스켈의 세계에, 많은 작품들이 존재합니다.

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를 사용하는 것이 현명할 겁니다.