devcken.io

Thoughts, stories and ideas.

Intuition, Insight

개발자에게 있어 중요한 능력은 수도 없이 많다. 문제 해결 능력, 합리적 사고력, 수학적 해석 능력, 습득력, 관련 기술 지식 등등, 대표적인 지식 노동자 중 하나라고 할 수 있다.

지식 노동자에게 필요한 능력에는 이외에도 무시할 수 없는 두 가지가 더 있는데, 바로 직관력과 통찰력이다.

직관력의 사전적 의미는,

[명사] 판단이나 추리 따위의 사유 작용을 거치지 아니하고 대상을 직접적으로 파악할 수 있는 능력.

또 통찰력의 사전적 의미는,

사물이나 현상을 통찰하는 능력.

통찰력의 경우 그 의미에 통찰이라는 단어가 또 들어가 좀 애매한데, 말하자면 '사물의 관찰력으로 꿰뚫어 보는 능력'이라 할 수 있다.

여기서 두 단어가 뜻하는 바가 비슷하면서도 오묘하게 다른데, 직관력은 겉으로 보기 힘든 어떤 사물의 면모를 볼 수 있는 힘을 말하며, 통찰력은 어떤 사물이나 현상에 대해서 포괄적, 다각적으로 볼 수 있는 힘을 말한다.

직관력과 통찰력이 있다면 개발자에게 어떤 점이 좋을까?

이 두 능력의 공통적인 부분은 바로 '해보지 않고도 알 수 있다'라는 것에 있다. 해보지 않고도 할 수 있다니 그 얼마나 대단한 능력인가? 그런데 그러한 능력을 얻기 위해서는 어떤 조건이 따른다.

어떤 일이나 사물에 대해 어느 정도 능통해야 한다. 어떠한 경험도 없이 직관과 통찰을 얻기란, 박혁거세처럼 알에서 태어나는 정도의 탄생 신화로 태어난 사람만 가능하지 않을까?

직관과 통찰은 그냥 생겨나는 것이 아니라 어떤 일이나 사물에 숙련되고, 또 다른 여러 가지 것들을 융합하여 생겨날 수 있다.

그런데 이 좋은 능력에도 문제가 있다. 그 중 하나가 측정하기 매우 어렵다는 것이다. 직관과 통찰은 사실 우리 주변에서 많이 일어나지만 그것으로 어떤 성과 또는 효과가 일어났는지 우리는 알아차리기 어렵다.

또 다른 문제는 바로 직관과 통찰의 오용과 남용이다. 내가 이 포스트를 통해 말하고자 하는 부분은 여기에 있다.

직관과 통찰은 맞고 틀리고의 문제라기 보다는, 어떤 정답을 얻어내기 위한 방법에 도달하기 위해 사용하는 지름길 같은 것이다. 물론, 직관과 통찰로 어떤 문제가 단번에 해결되는 경우도 아주 간혹 있지만, 아주 운이 좋은 경우라고 봐야 한다.

또, 직관력과 통찰력이 아주 높은 사람이라면, 그러한 행운의 빈도가 높긴 하겠지만, 그들의 그러한 능력만으로 일이 해결되는 경우는 아마 거의 없을 것이다.

주변에서 자신의 직관력 내지 통찰력을 맹신하는 개발자들을 꽤 보아왔다. 나도 가끔 느끼는 이러한 능력에 감탄(?)하곤 하지만, 사실 돌이켜보면 그 두 가지 능력으로 일이 말끔히 해결된 적은 없는 거 같다. 그렇다고 보기보다는 답을 얻어내는 과정을 좀 더 빠르고 쉽게 만들어주는 느낌이 강하다.

직관과 통찰이 가지는 문제점 중 하나는 그것이 주는 기쁨에 있다. 그것으로 어떤 문제가 해결된 것처럼 보일 때, 또는 해결될 것처럼 보일 때 우리는 어떤 카타르시스를 느끼는 것 같다.

사실 개발자는 이성적 존재여야 한다. 좀 더 명확하게 얘기하자면 과학자여야 한다. 과학자란 어떤 존재인가? 어떤 가설을 세우고 그 가설이 맞는지를 검증하기 위해 남들이 인정할 정도의 실험을 수도 없이 실행해 그 가설이 진리임을 알아내기 위한 사람 아닌가?

개발자도 그와 아주 비슷한 일은 한다. 어떤 문제에 대한 문제 해결 방법을 세우고 그 방법이 맞는지 검증하기 위해 테스트 코드를 작성해 그 방법이 옳다는 것을 증명해내는 존재다.

이 때 직관력과 통찰력은 아주 중요한 역할을 하는데, 그 중 몇 가지를 예로 들어보겠다.

첫번째, 문제 해결 방법, 즉 알고리즘을 만들 때 그 역할을 수행한다. '아 이 문제는 이렇게 하면 되겠다'라는 생각이 떠오르는 이유는 경험에서 나오는 것인데, 여기서 더 나아가 '이렇게 하면 되겠구나?'라는 생각이 든다면 그것은 직관력이나 통찰력에 의한 것일 가능성이 크다. 그리고 이때는 그야말로 문제를 파악할 수 있는 능력이 있어야 하므로 직관력이 좀 더 필요할 거 같다.

통찰력도 필요한데, 만약 문제 해결 방법이 쉽사리 떠오르지 않을때이다. '어떤 다른 측면은 없나?', '더 나은 방법은?' 등을 고민하는 순간 필요하다.

두번째로, 테스트 코드를 작성할 때 직관력과 통찰력이 필요하다. 90년대 말, 2000년대 초에 접어들면서 TDD에 대한 필요성이 대두되기 시작했다. 물론 우리 나라에는 그보다 훨씬 늦은 2000년대 말에 소개되기 시작했고 2010년대 중반에 들어서야 조금 자리 잡기 시작한거 같다. 개발자들은 테스트 코드 작성 자체에는 그리 힘들어하지 않는다. 물론, 테스트할 대량의 데이터를 만들고 그것을 대입해 프로덕션 레벨의 코드를 만든다는 것이 고난의 길이다. 하지만 진짜 힘들어하는 것은 '바로 무엇을 테스트할 것인가?', 즉 테스트 케이스 수립이다.

알고리즘 문제를 풀때는 테스트 케이스가 분명한 경우가 거의 대부분이다. 로직 자체에 대한 경우는 물론이고, 대부분 반복의 횟수, 경계값, 실행 속도, 메모리 사용량 등 정형화된 테스트 케이스를 통과시키면 되는 것들이 대부분이다.

그런데, 우리가 흔히 작성하는 코드들은 그러한 것들은 물론이고 테스트 케이스가 애매한 것들이 많다. 한번에 딱 떠오르지 않기도 하고, 너무 큰 덩어리라 어떻게 테스트해야할지 감이 오지 않는 경우도 많다. 물론, TDD에 숙련되면 이러한 것들이 좀 더 쉬워지기는 하겠지만, 이 때 발휘되어야 하는 통찰력의 힘을 무시할 수는 없다.

통찰력은 어떤 상황을 다각적으로 바라볼 수 있는 능력을 말하는데, 테스트 케이스 작성에 그 능력이 필요하다. 해결해야 하는 문제 그리고 그 방법, 그 방법을 검증하기 위한 것이 테스트 케이스인데, 그 상황을 다각적으로 볼 수 있어야 풍부한 테스트 케이스 작성이 이루어지고 그래야 확실한 증명이 된다.

그런데 이러한 직관력과 통찰력을 잘못 사용하는 경우가 많다. 사실 이 포스트 쓰는 나도 그런 경우가 많은데, 나의 직관력 또는 통찰력을 맹신하는 것이다.

머리 속으로 생각해보고 '우와 이거야!'라고 생각하는 것까지는 괜찮다. 당연히 그러한 과정이 필요하고 그 때 필요한 능력이 그 녀석들이니까! 그런데, 문제는 이 이후부터 발생한다. 그렇게 얻어낸 방법에 대해서 '증명'하지 않는다는 것이다. 그냥 믿어버린다. 물론 모든 경우를 테스트하기란 현실적으로 어려움이 있다. 하지만, 직관과 통찰로 믿었던 방법들이 계속해서 문제를 일으키고 있다면 자신의 맹신을 경계해야 한다.

개발자가 자신이 생각한 방법, 즉 직관이나 통찰을 이렇게 맹신하는 이유는 소위 말하는 '귀차니즘'의 영역에 있는거 같다. 물론 어떤 문제는 테스트할 엄두도 나지 않는 경우가 있다. 하지만 그 문제를 더 작은 하위 문제들로 쪼개서 하위 문제만이라도 검증하는 과정이 필요하다. 물론 해보지 않아서 그 방법을 모르기 때문일 수도 있지만, 사실 거기에는 '귀차니즘'이라는 기생충이 살고 있는 것이다.

'다른 일도 바빠!'라는 말로 합리화하지만, 결국 검증하지 않고 계속해서 방법만 만들어내고 검증하지 않은 상태가 이어지기에 일도 쌓이는거다. 일이 쉽사리 풀리지 않을 때는 시간을 확보해 충분한 테스트 환경을 만들어내고 그 테스트 환경으로부터 다시 생각해봐야 한다.

내가 세운 가설에 문제는 없는지, 내가 만든 테스트 케이스에 오류는 없는지, 다른 측면에서 테스트 케이스는 없는지...

직관력과 통찰력은 슈퍼 파워가 아니다. 아이언맨이 가진 Jarvis 같은 도구일 뿐이다. 도구는 이용과 숙련의 대상이지 믿음의 대상이 아니다.

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

'...란 책을 보고'라는 제목을 붙였지만, 사실 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로 메일을 보내거나 트위터로 연락을 주시기 바랍니다.