devcken.io

Thoughts, stories and ideas.

The brain-changing benefits of exercise on TED

TED 영상을 보면서 이런 에너지를 받은 적이 없다. 심지어, 내가 TED 영상을 엄청 많이 본 건 아니지만, 이런 형식의 발표는 처음이다. 세상에, 프레젠테이션 페이지도 2페이지였던거 같은데...

이름으로 보아 일본계 미국인인 것 같은데, 정말 멋진 분이다. 알고 보니 엄청 유명한 분인듯?

아무튼, 발표 말미에 청중 모두를 일으켜세워 운동을 시킨 발표자는 처음인 것 같다. 🤣

운동하자, 운동!

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  

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

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