자바스크립트 표준은 어떻게 만들어지는가?

이 블로그는 주로 코딩을 잘하는 방법에 대해 쓰지만, 요 며칠 반응이 뜨거워서 오늘은 잠시 쉬어가는 의미로 자바스크립트 표준이 어떻게 만들어지는지 소개하려고 합니다.

우리가 알고 있는 대게의 프로그래밍 언어는 만든 사람이 명확합니다. Java는 James Gosling, C#은 Anders Hejlsberg, Scala는 Martin Ordersky, F#은 Don Syme입니다. 그리고 JavaScript의 아버지는 모질라 재단의 공동 설립자이자 CTO를 거쳐 현재 CEO를 맡고 있는 (게이 반대 운동에 기부한 것 때문에 사임했음) Brendan Eich입니다.

자바스크립트 초기 버전은 Brendan Eich가 초기 브라우저인 네스케이프에 탑재하기 위해 만들었고, 인터넷 익스플로러도 JScript라고 이름 붙인 자바스크립트를 지원하기 시작했습니다. 두 언어가 분화되는 것을 막기 위해 네스케이프는 자바스크립트 표준화를 Ecma라는 국제 표준 단체로 넘깁니다.

JavaScript는 상표명이기 때문에 상표 분쟁을 막기 위해 Ecma에 등록된 자바스크립트의 이름은 EcmaScript가 되었습니다. Ecma 내에는 표준별로 Technical Committee(TC)라고 불리는 분과가 존재하는데 EcmaScript는 TC39에서 담당하고 있습니다. 그리고 자바스크립트는 더 이상 Brendan Eich가 혼자 만드는 언어가 아닌 위원회가 만드는 언어가 됩니다.

TC39 참여자들은 주로 구글, 애플, 이베이, 모질라, 인텔, jQuery 재단, 마이크로소프트 등 브라우저 벤더, 인터넷 대기업 소속이고 필요에 따라 프로그래밍 언어를 전공한 대학 교수나 전문가들이 초청되기도 합니다.

저는 웹브라우저 개발을 하면서 2008년 9월부터 2009년 8월 사이 1년 정도 TC39 멤버로 표준화 회의에 참여하였습니다. 당시 상황은 EcmaScript 3 (1999) 이후 10년 가까이 새로운 언어 명세를 내놓지 못하다가 EcmaScript 4 명세를 만들었으나 명세가 너무 커서 TC39 내에서 합의를 이루지 못하고 폐기한 상태였습니다.

제가 참여한 1년 기간은 당시에 Harmony라고 불렀고, 현재 EcmaScript 5로 알려진 표준을 정의하는 시기였습니다. 여기에 포함된 기능은 strict mode, getters and setters, 새로운 array 메소드들, JSON 지원 등이 있습니다. EcmaScript 4가 클래스나 패키지 등 꽤 큰 변화를 시도했던 것과 달리 명세 자체를 대폭 축소한 면이 있습니다. EcmaScript 4에서 논의했던 내용들은 지금 일부 EcmaScript 6에 포함되어 있고, 7으로 넘어간 것들도 있습니다.

보통 프로그래밍 언어 표준이 위원회를 통해 결정되면 망한다는 이야기를 많이 합니다. 서로 이해 관계가 다른 여러 회사들이 참여해서 각자 넣고 싶은 기능들을 넣기 때문에 과도하게 복잡한 언어가 되는 경향이 있기 때문입니다. 사실 자바스크립트도 EcmaScript 4를 정의할 때는 이런 이해 관계가 충돌이 있지 않았나 싶습니다.

하지만 EcmaScript 4를 폐기하고, 다시 점진적으로 EcmaScript 5를 내놓을 수 있었던 이유에는 TC39 특유의 분위기가 있지 않았나 생각합니다. 자바스크립트가 세상에 미치는 영향을 생각했을 때 TC39 참석자 수는 무척 적습니다. 각 회사 별로 보통 1명, 많아야 2명이 참석하는데, 참석자들이 보통 프로그래밍 언어 개발을 20-30년씩 하신 분들이라 회사가 달라도 서로를 무척 잘 알고 있고 존중하는 분위기였습니다.

TC39 미팅은 보통 2-3달에 한 번 이루어지고, 참여 회사들이 번갈아 가면서 장소를 제공하니다. 저는 TC39 미팅하면서 구글, 야후, 마이크로소프트, 모질라 등을 방문해 보았습니다. 회의는 이틀간 하는데, 아주 작은 기능 하나에 대해서도 수많은 경우의 수를 고려하면서 정말 하루 종일 쉬지도 않고 이야기합니다.

주제에 따라 프로그래밍 언어 전공한 교수들이 초대되기도 하는데, 참석자들 자체가 모두 프로그래밍 언어를 20-30년씩 하신 분들이라 논의할 때 전공 교수들도 쩔쩔 매는 모습을 몇 번이나 목격하기도 하였습니다.

중간 중간 쉬는 시간에도 기술에 대한 논의가 끊이지 않습니다. 보통 첫날 저녁에는 참석자들이 다 같이 근처 레스토랑에서 저녁 먹으면서 이야기하는데, 10명 이상의 대가들이 몇 시간씩 기술에 대해 열정적으로 이야기하는 것이 인상적이었습니다. 저는 당시 차가 없어서 JSON 포맷을 만든 것으로 유명한 Douglas Crockford의 차를 얻어 타고 이동했는데, 이런 저런 질문하면서 먼저 친해지려고 노력해 주신 것도 기억에 남았습니다.

당시에 저는 브라우저 성능 최적화 일을 하고 있었고, 스크립트 로딩 및 실행의 최적화를 위해 자바스크립트 소스가 아닌 일종의 어셈블리(바이트코드)를 만들자고 제안했었습니다. 물론 당시 분위기는 EcmaScript4를 포기하고, 꼭 필요한 것만 최소한으로 명세에 반영하자는 상황이었기 때문에 제 제안은 별로 설득력이 없었습니다(ㅠㅠ). 한동안 잊고 있었는데, 최근에 WebAssembly에 대한 이야기가 나오는 것을 보고 격세지감을 느낍니다.

물론 각 회사를 대표해서 온 만큼 회사의 이익을 위해 기능을 제안하는 경우도 있습니다. 제가 기억하는 일화 중에 하나는 IBM에서 당시 비지니스 어플리케이션을 쉽게 작성하기 위해 자바스크립트에 decimal number를 포함시키자는 제안을 했었습니다. 물론 다른 참석자들은 decimal number는 라이브러리로 처리하는 게 맞다는 의견이었고, 결국 이 제안은 받아들여지지 않았습니다.

놀란 것은 TC39에는 표결이 거의 없다는 점입니다. 자바스크립트가 산업에 미치는 영향을 생각해봤을 때 회사의 이해 관계로 첨예하게 의견 대립이 있을 것도 같지만, 대부분의 안건은 참석자 전원이 충분히 논의하고 거의 만장일치로 결정을 합니다. 논의가 충분치 않은 경우 섣불리 결정하지 않고 다음 회의에서 전문가를 불러 의견을 듣고 이야기를 이어나가는 모습도 인상적이었습니다.

전세계에 영향을 미치는 중요 기술의 표준화가 생각보다 실리콘밸리 동네 아저씨(?) 모임 같았습니다. 서로 오랜 기간 지식과 의견을 교환하고, 더 좋은 방법을 찾을 때까지 성숙하게 논의할 수 있는 능력을 가진 동네 아저씨들 말이죠. 한국에서도 웹 관련 몇몇 모임이나 표준화 회의를 참석해 본 경험이 있고 각자 자기 회사 이해 관계에만 민감하게 굴던 모습과 대비되어서 수준 차이라는 게 어떤 건지도 실감하였습니다.

제가 TC39 자체에 별로 기여한 바는 없지만, 회의 참석하여 이런 모습들을 보며 많이 배우고 느꼈습니다. 한국에서 온 이름도 모르는 엔지니어한테도 모두들 친절하게 먼저 말을 걸고, 배려해주시는 모습에 감동받았고 나도 나중에 좋은 엔지니어가 되면 그렇게 해야지라고 생각했는데 아직 갈 길이 멀기만 하네요.

전역 변수/지역 변수

C/C++ 프로그램에 익숙한 분들은 변수를 크게 전역 변수/지역 변수로 구분하는데 익숙하실 겁니다. 하지만 클로저를 지원하는 JavaScript에서는 함수 안에 함수를 중첩해서 많이 선언하기 때문에 지역 변수의 스펙트럼이 다양해집니다.

예를 들어, JavaScript에서는 전역 네임스페이스를 오염시키지 않기 위해 최상단에 익명 함수를 하나 정의하고 곧바로 호출하는 용법을 많이 사용합니다.

(function () {
    var x = 1;

    function foo() {
        return x;
    }

    x++;

    function bar() {
        return x;
    }

    console.log(foo());
    // 2
    console.log(bar());
    // 2
})();

여기서 변수 x는 엄밀한 의미에서 익명 함수의 지역 변수이지만, foo()bar() 함수 입장에서 봤을 때는 두 함수가 서로 공유하는 전역 변수가 됩니다. foo() 함수에서 x가 1일때 값을 캡춰했지만, x++이 실행되었기 때문에 foo() 함수는 2를 리턴합니다.

이번에는 foo() 함수를 다음과 같이 확장해 봅시다. 여기서 변수 yfoo() 함수의 지역 변수이지만, foo1(), foo2() 함수의 전역 변수가 됩니다.

    function foo() {
        var y = 2;

        function foo1() {
            return y;
        }

        y++;

        function foo2() {
            return y;
        }

        console.log(foo1());
        // 3
        console.log(foo2());
        // 3

        return x;
    }

즉, 클로저가 있는 언어에서는 전역 변수와 지역 변수의 구분이 모호해지는 것을 알 수 있습니다. 앞서 왜 변수가 나쁜가?라는 글에서 제기한 문제도 여기에 있습니다. 클로저가 캡춰한 변수 i는 루프 내의 모든 익명 함수가 공유하는 전역 변수가 되기 때문입니다.

function makeAdders(n) {
    var adders = [];
    for (var i = 0; i < n; i++) {
        adders.push(function (x) { return i + x; });
    }
    return adders;
}

var adders = makeAdders(10);
var add5 = adders[5];
var r = add5(2);
console.log(r);
// We expect 7, but 12 is printed.

참고로 프로그래밍 언어가 클로저가 캡춰하는 변수를 복사할 것인지, 공유할 것인지는 정답이 없습니다. 어느 쪽을 선택하든 임의의 선택이 되는 거죠. 실제로 프로그래밍 언어마다 방식이 다릅니다. 문제는 변수라는 개념이 클로저와 결합하여 프로그래밍 언어 자체를 복잡하게 만드는 요소가 되고, 개발자 입장에서는 프로그래밍 언어마다 다른 임의의 규칙을 기억하고 써야 하기 때문에 프로그래밍의 복잡도를 더 하는 요인이 됩니다.

왜 변수가 나쁜가?

프로그래밍 입문서의 문제점에서 변수의 사용은 나쁜 프로그래밍 습관이니, 프로그래밍 입문서에서 변수 사용을 장려하지 않는 것이 좋겠다는 이야기를 했습니다. 그런데 입문자가 아니라 현업 개발자들조차 변수 사용이 왜 나쁜 습관인지, 왜 복잡한 소프트웨어 작성에 문제가 되는지 잘 모르고 계신 것 같아서 관련 내용을 보강합니다.

일단 “전역 변수(global variable)는 나쁘다”라는 말로 시작하겠습니다. 이 말에는 비교적 이견 없이 많은 개발자들이 동의할 것입니다. 전역 변수의 문제점을 정리해보면

  • Non-locality – 변수 범위가 넓어서 이해하기가 어려움. 프로그램 여기 저기서 값을 읽거나 변경할 수 있으므로 사용처를 기억하기 어려움.
  • No Access Control or Constraint Checking – 프로그램 어디서나 값을 읽거나 변경 가능하고, 접근 제한이 없어서 사용과 관련 규칙을 잊거나 깨먹기 쉬움.
  • Implicit coupling – 프로그램 내 변수와 함수 등 여러 요소들과 커플링이 심해짐.
  • Concurrency issues – 동시에 여러 쓰레드가 값을 변경할 수 있으므로 동기화가 필요함.
  • Namespace pollution – 전역 변수는 모든 곳에서 보이므로 네임스페임스가 오염됨.
  • Memory allocation issues – 전역 변수가 메모리 할당 외에 side effect가 있으면 문제 발생 소지가 큼. (상호 의존 등)
  • Testing and Confinement – 테스트를 위한 깨끗한 환경을 만들 수 없으므로 테스트하기가 어려워짐.

우리가 전역 변수의 문제점을 이야기할 때 보통 문제가 “전역”에 있는지 “변수”에 있는지 구분하지 않고 “전역 변수”가 문제라고 이야기합니다. 하지만 각 요소를 하나씩 바꿔서 다시 질문해 보면 어떨까요? 전역 상수는 뭐가 문제일까요? 값을 쓰지 않기 때문에 앞서 언급한 문제점 중 “namespace pollution” 외에 다른 문제는 일어날 수가 없습니다. 반대로 지역 변수는 뭐가 문제일까요? “namespace pollution” 외에는 모든 문제가 일어납니다. 다만, 범위가 작기 때문에 전역 변수에 비해 피해의 규모가 작을 뿐입니다.

지역 변수가 문제가 되는 예를 하나 보겠습니다. 다음 makeAdders() 함수는 인자 n을 받아서 0을 더해주는 함수, 1을 더해주는 함수, … n을 더해주는 함수의 배열을 리턴합니다. 따라서 adders[5]에는 5를 더해주는 함수가 들어 있게 됩니다. add5(2)는 5+2=7을 리턴할 것으로 예상되지만, 실제로 코드를 돌려보면 놀랍게도 12가 리턴됩니다. 이런 문제가 발생하는 이유는 클로저에서 캡춰한 변수 i가 루프를 돌면서 계속 갱신되어 루프가 끝날 때 10이 되는데, 그 전에 캡춰한 모든 클로저의 i값도 10으로 갱신되기 때문입니다.

function makeAdders(n) {
    var adders = [];
    for (var i = 0; i < n; i++) {
        adders.push(function (x) { return i + x; });
    }
    return adders;
}

var adders = makeAdders(10);
var add5 = adders[5];
var r = add5(2);
console.log(r);
// We expect 7, but 12 is printed.

이 문제는 C#에도 있었습니다. 그래서 C#팀의 Eric Lippert는 Closing over the loop variable considered harmful 글에서 아에 루프 변수를 클로저 내에서 사용하지 말라는 이야기도 했습니다.

그런데 C# 5에서는 루프 변수가 논리적으로 루프에 속하는 것으로 보고, 루프 변수를 클로저에서 캡처하면 항상 새로운 값을 복사해서 주게 바뀌었습니다. 이렇게 바꾸면 이전 버전 C#과 하위호환성이 깨지는데도, 심각한 문제라고 보고 수정을 감행한 겁니다. 이런 예를 보면 C# 언어 설계자들조차 변수와 클로저의 조합이 어떤 문제를 일으킬지 충분히 검토를 못했다는 사실도 알 수 있습니다.

LINQ와 Rx를 만든 Erik Meijer가 최근한 ACMQueue에 기고한 The Curse of the Excluded Middle을 보면 저보다 더 극단적입니다. Scala나 F# 같은 함수 언어조차도 side effect로 인해 심각한 문제를 겪고 있으니 좀 더 근본주의적으로 접근해서 모든 effect를 순수 함수 언어로 관리해야 한다는 주장입니다. 여기서 side effect는 변수뿐만 아니라 IO, exception, 쓰레드 등까지 모두 포함하고 있습니다.

소프트웨어의 복잡성은 상태(state)에 있고, 좋은 프로그래밍은 결국 상태를 효과적으로 관리하는 방법을 찾는 겁니다. 객체지향 프로그래밍은 캡슐화(encapsulation)를 통해 서로 연관된 상태를 한 곳에 모아서 상태 관리의 복잡성을 줄이고, 함수형 프로그래밍은 불변 데이터 타입(immutable data type)을 통해 아에 상태를 만들지 않거나, 모나드와 같은 수학적인 구조를 이용해 상태를 효과적으로 제어합니다.

그런데 생활 코딩뿐만 아니라 대부분의 프로그래밍 입문서는 굳이 변수가 필요하지 않은 부분에서 너무 변수를 많이 사용하는 경향이 있습니다. 덕분에 우리가 초중고 과정을 통해 배운 수학적인 함수의 계산 방법이 훨씬 더 직관적이고 자연스러움에도 불구하고, 프로그래밍 입문 과정을 통해 이런 계산 방식을 잊고 프로그램의 수행을 메모리를 읽고 쓰는 것으로 해석하는 방법을 배우게 됩니다.

변수의 사용을 될 수 있으면 피하는 게 입문자들에게 프로그래밍을 더 쉽게 가르치는 방법이고 더 좋은 프로그래밍 스타일이기도 합니다. 변수는 꼭 필요한 경우에만 제한적으로 사용하는 일종의 “블랙 매직”으로 생각하고, 프로그래밍 과정에서도 될 수 있으면 나중에 다루는 게 오히려 프로그래밍을 제대로 가르치는 방법이라고 생각합니다. SICP에서 mutable data를 251페이지에 가서야 가르치는 이유에 대해서도 생각해 볼 필요가 있습니다.

지금 입문서들은 더 쉽게 가르치는 것처럼 하면서 실은 훨씬 더 어렵게 가르치고 있습니다.

리팩토링

아래 코드는 while 문의 사용법을 보여주기 위한 예제 코드입니다.

var i = 0;
while(i < 10) {
    document.write('coding everybody');
    i++;
}

이 코드의 가장 큰 문제점은 요구사항과 실제 코드 사이의 괴리입니다. 요구사항은 무척 간단합니다. “coding everybody”라는 문자열을 화면에 10번 출력하는 것이죠. 그런데 코드는 안 간단합니다. 변수 i를 0으로 초기화하고, while 문에서 10보다 작은지 조건을 검사하고, 참이면 화면에 문자열을 한 번 출력하고, 변수 i의 값을 1 증가시킨 다음에 다시 while 루프 처음으로 돌아갑니다.

또한 아주 작은 코드이지만, 이 코드에는 관심사(concern)가 2가지가 존재합니다. 반복과 출력입니다. 그리고 두 가지 관심사를 한 번에 처리하기 때문에 “실제”보다 복잡한 코드가 되었습니다. 두 가지 관심사를 각각의 함수로 분리해 보겠습니다.

function times(n, f) {
    for (var i = 0; i < n; i++) {
        f();
    }
}

function print() {
    document.write('coding everybody');
}

times(10, print);

간단한 분리처럼 보이지만, 이제 출력에 대한 관심사는 print() 함수로, 반복은 times() 함수로 나눠서 이해할 수 있고, times() 함수의 정의를 보지 않고서도 직관적으로 이 코드가 어떤 일을 수행하는지 바로 알 수가 있습니다. 더 이상 변수 선언이나 갱신을 신경쓸 필요 없으므로 코드만 봐도 요구사항이 한 눈에 보입니다.

관심사의 분리(separation of concerns)는 소프트웨어 엔지니어링의 가장 기본적인 원리 중 하나입니다. 하지만 소프트웨어 엔지니어링이란 말이 뭔가 크고 거창한 것이라는 생각에, 많은 개발자들이 이 정도로 작은 코드 수준에서 관심사의 분리 원리가 적용되어야 한다고 생각하지 않는 경향이 있습니다.

부가적으로 times() 함수를 다른 곳에서 재활용할 수도 있습니다. 하지만 times() 함수가 재활용되지 않더라도 한 함수가 한 번에 하나의 일만 해야 한다는 건 객체지향 프로그래밍의 원리인 SOLID의 단일 책임의 원칙(single responsibility principle)과도 일맥상통합니다.

이렇게 내부 동작의 변경 없이 이렇게 코드 가독성을 높이고 유지 보수를 더 쉽게 할 수 있게 수정하는 행위를 리팩토링이라고 부릅니다.

일단 분리하고 나면 필요에 따라 times() 함수의 구현을 바꿀 수도 있습니다. 예를 들어, (JavaScript VM에 꼬리 재귀 최적화(tail call optimization)가 추가되어 재귀 함수의 성능이 루프와 다르지 않는 상황이 온다면) for 문이 아니라 재귀 함수(recursion)를 이용하도록 구현을 바꿀 수도 있을 겁니다. 이 과정에서 만약 times()라는 함수가 별도로 분리되어 있지 않았다면, 코드 변경 시 다른 관심사(출력) 코드를 실수로 건드려 버그를 만들 수도 있었을 겁니다.

function times(n, f) {
    f();
    if (n > 1)
        times(n - 1, f);
}

이렇게 리팩토링된 코드는 테스트하기도 더 편합니다. document.write() 함수는 DOM이라는 전역 공유 상태(global shared state)를 건드리는 함수이기 때문에 테스트하기가 쉽지 않은 반면에 times() 함수는 함수 인자에만 결과값이 의존하는 순수 함수(pure function) 혹은 참조 투명(referential transparent)한 함수이기 때문에 별도의 환경 셋업 없이 다음과 같이 쉽게 테스트가 가능합니다. 심지어 브라우저가 아니어도 테스트가 가능합니다.

function test_times() {
    var x = 0;
    times(10, function () { x++; });
    assert(10, x);
}

그리고 보니 times라는 함수는 워낙 일반적인 함수라서 이미 underscore.jslodash가 제공을 하고 있습니다. 우리가 작성한 코드와 달리 에러 처리 코드도 포함되어 있고, 추가로 context 인자를 넘길 방법을 제공하므로 상용 코드에서는 해당 라이브러리를 쓰는 것이 더 좋은 방법일 수 있습니다.

_.times(10, print);

프로그래밍 입문서의 문제점

모바일앱, 웹서비스 등을 스스로 만들어보려는 사람이 늘면서 코딩을 배우는 사람들도 늘고 있습니다. 페이스북 그룹 “생활 코딩”에 가입자가 4만명 이상 있고, 여러 온오프라인 행사들을 개최하는 것을 보면 코딩을 배우고자 하는 열기가 느껴지기도 합니다.

하지만 입문자들에게 코딩을 가르치는 방식을 보면 우려가 되는 부분이 있습니다. 많은 분들이 우려하는 전산 전공 지식이 아닌 “코딩”만 가르친다는 문제 제기를 하려는 것은 아닙니다. 코딩만 놓고 봐도 나쁜 습관을 가르치고 있는 것이 문제입니다.

가장 큰 문제는 변수를 값을 담을 수 있는 용기로, 변수 값의 변경(mutation)을 계산 방법으로 설명하는 것입니다. 다음의 생활코딩 JavaScript 강의 변수 편에서 인용한 변수에 대한 설명입니다.

변수(Variable)는 (문자나 숫자 같은) 값을 담는 컨테이너로 값을 유지할 필요가 있을 때 사용한다. 여기에 담겨진 값은 다른 값으로 바꿀 수 있다.

이 설명이 기술적으로 틀렸다는 뜻은 아닙니다. 컴퓨터 프로그램은 결국 메모리를 읽고 쓰면서 계산을 수행하기 때문입니다. 문제는 입문자들에게 프로그래밍을 “풀고자 하는 문제에 대한 답을 컴퓨터가 알아들을 수 있게 메모리 읽기/쓰기로 표현하라”고 가르치는 것입니다. 반복문에 대한 설명을 보면 이런 경향이 명확해 집니다.

다음은 생활 코드 JavaScript 강의 반복문 편에서 인용한 “coding everybody”라는 문장을 10번 출력하는 코드입니다.

var i = 0;
// 종료조건으로 i의 값이 10보다 작다면 true, 같거나 크다면 false가 된다.
while(i < 10){
    // 반복이 실행될 때마다 coding everybody
이 출력된다.
 줄바꿈을 의미하는 HTML 태그
    document.write('coding everybody
');
    // i의 값이 1씩 증가한다.
    i++
}

요구사항은 그저 같은 문장을 10번 반복해서 출력하는 것인데, 변수 i를 선언하고 while 루프를 돌면서 i를 하나씩 증가시키고 i가 10보다 작은지 판단해서 루프를 빠져나오는 코드를 예로 보이고 있습니다.

이미 이런 스타일에 프로그래밍에 익숙해지신 분들은 입문자들에게 이런 스타일로 프로그래밍을 가르치는 게 얼마나 저수준(기계에 가까운) 사고를 요구하는지 이해하기 힘드실 수도 있습니다. 위 코드를 다시 읽어 봅시다.

  • 변수 i가 가르키는 메모리 공간이 있고 이 공간에 값 0을 초기화합니다.
  • while 루프에서 변수 i가 가르키는 메모리 공간의 값을 읽어 이 값이 10보다 크면 루프를 빠져 나가고 아니면 다음 코드를 수행합니다.
  • 문자열을 출력하고, 변수 i가 가르키는 메모리 공간의 값을 읽어 1을 더한 다음에 이 값을 다시 변수 i가 가르키는 메모리 공간에 씁니다.
  • 다시 while 루프를 반복합니다.

변수 그 자체가 값이 아니라 메모리 공간에 대한 포인터 개념으로 설명을 하고 있고, 실제로 이 메모리 공간의 값을 변경해서 계산을 수행하고 다음 수행될 코드를 결정하기 때문에 얼핏 간단해 보이는 이 코드는 사실상 어셈블리에 가까운 코드인 걸 알 수 있습니다.

어떤 문자열을 10번 출력하는 프로그램은 다음과 같이 요구사항 그대로 말로 옮긴 코드가 되어야 합니다.

_.times(10, function () {
  document.write('coding everybody');
});

저는 입문자에게 변수라는 개념을 가르칠 때 단순히 이름 붙이기(바인딩)로 가르치는 것이 적절하다고 생각합니다. 예를 들어, 아래 코드에서 x는 10, y는 20, z는 10+20입니다. x는 10이라는 값을 담고 있는 메모리 공간, y는 20이라는 값을 담고 있는 메모리 공간, zx 메모리 공간이 담고 있는 값과 y 메모리 공간이 담고 있는 값의 합을 계산하여 그 결과를 담고 있는 메모리 공간으로 생각하는 것과 복잡도 면에서 큰 차이가 있습니다.

var x = 10;
var y = 20;
var z = x + y;

물론 어떤 프로그램이 값의 변경(mutation)이 없이 유용한 일을 할 수는 없습니다. 하지만 mutation은 프로그램이 복잡해지는 가장 큰 이유이고, 프로그래밍에 입문할 때부터 mutation이나 side effect는 꼭 필요한 경우에만 조심스럽게 하도록 가르치는 것이 필요하다고 생각합니다.

케이팝스타 같은 오디션 프로그램을 보면 심사위원들이 가장 큰 문제로 지적하는 것이 “나쁜 습관”입니다. 노래 연습을 열심히 한만큼 나쁜 습관은 더 고치기가 어렵고, 그래서 나이 어린 참가자들에 비해 성인 참가자들의 발전 가능성을 더 낮게 봅니다. 프로그래밍도 마찬가집니다. 사람은 튜링 머신이 아닌데, 모든 문제를 메모리 읽고 쓰는 문제로 보는 습관은 복잡한 소프트웨어를 작성할 때 가장 큰 걸림돌이 됩니다. 그리고 이런 사고 방식은 오래될수록 고치기 어렵습니다. 그래서 처음 프로그래밍에 입문할 때 어떻게 배우느냐가 중요합니다.

Encoding algebraic data type in JavaScript

JavaScript lacks algebraic data type, but we can emulate it because JavaScript has first-class functions.

Here’s a simple Haskell program declaring Tree data type with Bud, Leaf and Tree data constructors. treeSum calculates the sum of leaf nodes by iterating all nodes in the given tree.

data Tree = Bud | Leaf Int | Tree Tree Tree deriving Show

treeSum :: Tree -> Int
treeSum Bud = 0
treeSum (Leaf n) = n
treeSum (Tree t1 t2) = treeSum t1 + treeSum t2

main = do
	let t0 = treeSum Bud
	putStrLn $ show t0
	let t1 = treeSum (Leaf 2)
	putStrLn $ show t1
	let t2 = treeSum (Tree (Leaf 3) (Tree (Leaf 5) Bud))
	putStrLn $ show t2

In JavaScript, we can emulate the data constructors of Tree with higher order functions. The trick used here is to pass b, l and p as arguments to data constructors. Only one of these 3 arguments is used by each data constructor:

  • b is a value for Bud.
  • l is a function taking an integer n as an argument and returns a value for Leaf.
  • p is a function taking left and right trees as arguments and returns a value for Tree.

(ES6 let and arrow are used for conciseness.)

let Bud = b => l => p => b;
let Leaf = n => b => l => p => l(n);
let Tree = t1 => t2 => b => l => p =>
           p(t1(b)(l)(p))(t2(b)(l)(p))

You can think of Bud as a value by itself. For Leaf and Tree, you can create each node by partially applying arguments.

var t0 = Bud;
var t1 = Leaf(2);
var t2 = Tree(Leaf(2), Bud);

With this encoding scheme, defining treeSum is trivial. You just need to pass 3 functions to handle Bud, Leaf and Tree respectively. If w is Bud, it returns 0. If w is a Leaf node, it returns n. If w is a Tree node, it returns the sum of left and right trees.

let treeSum = w => w(0)(n => n)(x => y => x + y);

The client code looks almost the same to Haskell.

var v0 = treeSum(Bud);
console.log(v0);

var v1 = treeSum(Leaf(2));
console.log(v1);

var v2 = treeSum(Tree(Leaf(3))(Tree(Leaf(5))(Bud)));
console.log(v2);

The origin of notation λ in lambda calculus

Here is an extract from the impact of lambda calculus in logic and computer science.

In Russel and Whitehead's [1910-13] Principia Mathematica the notation for function f with f(x) = 2x + 1 is 2x̂ + 1. Church originally intended to use the notation x̂.2x + 1. The typesetter could not position the hat on the of the x and placed it in front of it, resulting in ^x.2x + 1. Then another typesetter changed it into λx.2x + 1.

문제는 알고리즘만이 아니다.

오늘 임백준님이 ZDNET에 기고하신 문제는 알고리즘이다 글이 페이스북에 많이 회자되었습니다. 프로그래머가 문제 해결 능력을 함양해야 한다는 글의 취지에는 동의하지만, “알고리즘=문제 해결 방법”이라는 대유법에는 동의하기가 어렵습니다.

문제 해결에 알고리즘이나 알고리즘적인 사고 방식이 도움이 되는 것은 사실이지만, 문제 해결 능력이란 알고리즘보다 훨씬 더 큰 능력입니다. 임백준님이 인용한 알고리즘의 정의는 “수학적인 문제를 해결하거나 컴퓨터 프로세스를 완결하기 위해서 차례로 뒤따르는 단계의 집합”입니다. 이 정의 어디에도 실제 소프트웨어 개발에 필요한 요구사항 분석이나 설계, 테스트, 유지 보수 등의 이슈가 언급이 안 되고 있습니다.

게임 개발을 예로 들어 봅시다. 10-20명 정도의 팀을 꾸려 1년간 모바일 게임을 개발하는 상황을 가정해 봅시다. 새로 시작한 프로젝트인 만큼 신나서 다들 열심히 코딩을 하지만, 시간이 지날수록 코드는 복잡해지고 출시 때쯤 되면 간단하 버그 하나 못 잡아 2-3일씩 시간을 쓰거나 간단한 수정 사항 하나 반영 못해서 회사와 싸우는 경우가 비일비재합니다. 알고리즘적인 사고 방식만 있었으면 이런 일이 일어나지 않았을까요?

문제 해결에 필요한 여러 능력 중 알고리즘만 유독 강조되는 또 다른 이유는 구글을 필두로 미국 IT 회사들이 면접 때 알고리즘 문제를 많이 내기 때문인 것으로 보입니다. 하지만 이게 실제 업무가 아니라 면접이라는 점을 감안해야 합니다. 지원자의 문제 해결 능력을 확인하고 싶은데, 짧은 면접 시간 동안 그나마 가장 효과적인 방법이 알고리즘적인 사고를 보는 것이지, “알고리즘=문제 해결 능력”이라고 생각하는 게 아닙니다.

CMU 교수인 Jeannette M. Wing이 CACM에 게재한 글 중 Computational Thinking이란 글이 있습니다. Wing 교수가 정의한 Computational Thinking(CT)은 열린 문제에 대한 정답을 찾는 과정을 일반화하는 프로세스입니다. 정답이 정해져 있는 것이 아니라 여러 변수를 고려해서 다양한 답을 낼 수 있고, 컴퓨터뿐만 아니라 사람을 이용해도 됩니다.

CT는 알고리즘뿐만 아니라 분해(decomposition), 데이타 표현(data representation), 일반화(generalization), 모델링(modeling) 등 컴퓨터 과학 혹은 소프트웨어 엔지니어링에서 문제 해결에 사용하는 다양한 기법들을 총동원하여 문제를 푸는 과정을 말합니다. 알고리즘은 CT에서 요구하는 여러 기술 중에 하나일 뿐입니다.

코딩을 “특정 플랫폼, 특정 언어, 특정 API”라고 좁은 의미로 정의하고 문제 해결 능력인 알고리즘과 대비해서 설명하는 것도 문제가 있습니다. 프레임 자체을 2분법적으로 짜고 알고리즘과 코딩을 대비해서, 알고리즘은 문제 해결 능력이고, 코딩은 단순 기술이라는 식으로 설명을 하면 알고리즘과 코딩 어느 쪽에도 속하지 않지만, 좋은 코드를 작성하기 위해 필요한 수많은 지식이 갈 곳이 없기 때문입니다.

또 단순히 알고리즘이 문제라고 설명하면 수많은 한국 IT 회사들이 겪고 있는 문제를 잘못 진단하게 됩니다. 미국 IT 회사가 알고리즘적인 사고만 보고 사람을 뽑아도 되는 이유는 웬만한 IT 회사들이 이미 좋은 개발 문화를 가지고 있고, 누가 들어오든 문제 해결 능력이 있고 사람만 똑똑하면 코딩을 잘할 수 있는 환경을 만들어 놓았기 때문입니다. 반대로 한국 IT 회사는 좋은 코드를 작성하기 위한 기본적인 개발 문화도 환경도 없는 경우가 없기 때문에 알고리즘적인 사고만 뛰어난 개발자를 뽑으면 슈퍼개발자가 되어 회사를 망칠 확률이 더 높습니다.

코딩은 학교에서 못 가르치는 게 맞지만, 그렇다고 학원에서 쉽게 배울 수 있는 것도 절대 아닙니다. 코딩은 혼자 배우는 게 아닙니다. 프로그래밍 언어를 배우는 과정도 마찬가지입니다. 문법(syntax)이나 의미(semantic)야 학원을 다니든 독학을 하든 쉽게 배울 수 있지만, 해당 언어 커뮤니티가 사용하는 용법(idiom)을 익히는데 시간과 노력을 투자해야만 합니다. 또한 혼자 세상과 소통하며 오픈소스 개발자 할 것 아닌 이상, 결국 코딩은 회사에서 동료들에게 배우는 겁니다. 바꿔 말해, 누군가가 코딩을 못 하는 이유는 주변 동료들이 코딩을 못 하기 때문입니다.

알고리즘만 문제가 아니라 알고리즘도 코딩도 그외 모든 것도 다 문제입니다.

코딩 교육

요즘 초중고 학생, 일반인들을 위한 코딩 교육에 대한 관심이 많은데, 이 글에서는 전산 혹은 컴퓨터공학 전공자에 대한 코딩 교육을 이야기해 보려고 합니다.

우리나라의 코딩 교육은 영어 교육과 닮은 면이 있습니다. 비슷한 경제 수준의 다른 나라들에 비해 교육열이 낮거나 쓰는 시간과 비용이 적은 편이 아닌데도 결과가 좋지 않다는 점입니다. 10년 이상 영어 공부를 해도 간단한 영어 문장도 말하지 못하고, 대학 4년을 컴퓨터공학과에서 공부했는데도 간단한 스마트폰 어플, 웹 서비스도 못 만드는 현실이 그렇습니다.

여기에는 여러 이유가 있겠지만 저는 가장 큰 문제 중 하나로 읽기 교육의 부재를 꼽고 싶습니다. 여기서 읽기는 인문 교양 서적이나 문학 서적의 독서 부족를 이야기하는 것이 아니라 코드 읽기를 말합니다. C++, JavaScript, C# 등 프로그래밍 언어만 하나 달랑 배운 후에 곧바로 코드를 작성하는 자체가 어불성설입니다. 이건 국어 문법만 달랑 배우고 글쓰는 일을 업으로 삼는 것과 마찬가지입니다. 글이야 쓸 수 있겠지만, 절대로 좋은 글을 쓸 수는 없습니다.

현업에 와도 좋은 코드를 읽을 기회는 드뭅니다. 선배들도 비슷한 시행 착오를 거쳐서 겨우 돌아만 가는 코드를 짜놓은 경우가 많고, 이 코드를 유지보수하면서 코드 품질에 대한 기준을 잡기 때문에 5년 10년을 개발해도 실력이 늘기가 어려운 악순화에 빠지게 됩니다. 대부분의 개발자는 좋은 코드를 한 번도 보지 못하고 비슷한 수준의 작업만 무한히 반복하고 있습니다.

게임 개발자는 이런 상황에 노출되기가 더 쉽습니다. 게임의 특성상 기존 코드를 활용하기 보다 매번 코드를 새로 작성하는 경우가 많습니다. 오픈소스로 공부하고 싶어도 게임의 일부 라이브러리가 아닌, 게임 코드 자체가 공개된 경우는 잘 없습니다. 결국 대부분의 게임 개발자는 내가 작성한 코드, 우리팀이 작성한 코드외에 다른 사람의 코드를 읽어본 경험이 거의 없게 됩니다.

해결책은 결국 좋은 코드를 많이 읽는 것밖에 없습니다. 다행히 점점 더 많은 회사들이 오픈소스로 코드를 공개하고 있고, GitHub나 여러 오픈소스 커뮤니티 사이트를 통해 쉽게 소스코드를 내려받을 수 있습니다. 많은 분들이 오픈소스 프로젝트에 실제 참여하는 것을 권장하지만, 저는 최소한 잘 작성된 코드를 읽는 것만으로 실력 성장에 많은 도움이 된다고 생각합니다. 좋은 작가가 되기 위해 고전을 읽는 것과 마찬가지입니다.

문제는 남아 있습니다. 사실 읽을 책이 없어서 독서를 안 하는 게 아니라 독서하는 습관이 없기 때문에 독서를 할 줄 모르는 게 문제인 것처럼, 인터넷에 좋은 오픈소스 프로젝트가 널렸어도 소스 코드를 읽고 공부하는 방법을 모르는 개발자에게는 그림의 떡일 수밖에 없습니다.

이 문제에 대한 답도 고전 읽기에서 찾아야 한다고 생각합니다. 가장 좋은 방법은 스터디 모임을 만드는 것입니다. 관심 있는 분야가 맞는 사람끼리 코드 읽는 모임을 만들어서 각자 코드를 파악하고 모르는 부분을 서로 묻는 자리를 만든 것도 좋은 방법입니다. 고전이든 코드든 읽을 때는 이해한 것 같지만, 막상 다른 사람에게 설명하려고 하면 막히는데, 이는 단순히 익숙해진 것뿐이지 이해한 것이 아니기 때문입니다.

사실 오픈소스는 공부하기 쉬운 편은 아닙니다. 겉으로는 외부 참여자를 독려한다고 하지만, 실제 중요 프로젝트들은 대부분은 해외 대기업들이 주도하고 있고, 오픈소스에 참여할 수 있는 실력 있는 개발자들의 수도 한정되어 있습니다. 따라서 외부 개발자의 참여를 독려하기 위한 자료나 문서를 제공하는 경우도 드뭅니다. 다행히 The Architecture of Open Source Applications와 같이 여러 오픈소스 아키텍처에 대해 소개하는 책이 나오기도 했습니다.

저는 우리나라에서 개최되는 개발자 컨퍼런스에 대해서는 부정적입니다. 세미나 혹은 컨퍼런스라고 부르기에 민망할 정도로 그저 외국의 최신 기술을 소개하는 수준이고, 해당 기술의 개발자도 아니면서 최신 기술만 다루려고 하다보니 내용도 수박 겉핥기에 그치고 있습니다. 이런 컨퍼런스는 개발자들이 뭔가를 배우고 싶다는 갈증을 순간적으로만 해소시켜주지 개발 역량을 실제로 개선해주지 못합니다.

우리나라가 다른 기술의 수준에 비해 유독 소프트웨어 역량만 떨어지는 것이 우연히 아니라고 생각합니다. 글을 읽고, 비판적으로 생각하고, 쓰는 훈련이 부족한 나라는 소프트웨어를 제대로 만들 수 있는 기초 역량도 부족할 수밖에 없습니다. 같은 맥락에서 서두에 언급한 초중고 코딩 교육이라는 것도, 이런 근본적인 문제를 해결하는 방법을 찾는 데 집중해야 합니다. 12년을 국영수를 가르쳐도 못 하는 논리적 사고가 똑같은 방식의 코딩 교육을 통해 갑자기 생길까요?

JavaScript Array Comprehension

Python은 쉽게 리스트를 만들 수 있는 방법으로 list comprehension을 제공합니다.

0에서 10까지 제곱의 리스트는 list comprehension으로 다음과 같이 표현할 수 있습니다. 여기서 range(10)[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 리스트를 리턴하고, 전체 식은 각 원소 x를 제곱(x**2)하여 새로운 리스트를 리턴합니다.

>>> [x**2 for x in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

리스트 [1,2,3][3,1,4]에서 각각 xy를 뽑아 xy가 같지 않으면 튜플 (x, y)의 리스트를 리턴하는 것도 다음와 같이 간결히 표현할 수 있습니다.

>>> [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
[(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]

Python은 언어 자체가 for, in, if 등의 키워드를 인지하고, 위와 같이 list comprehension을 이용해 리스트를 쉽게 생성, 조작할 수 있게 해줍니다.

JavaScript의 배열(array)를 이용해서 비슷한 일을 할 수 없을까요? 물론 JavaScript는 comprehension을 위한 특별한 문법을 제공하지 않습니다. 하지만 고차함수를 이용하여 Python의 list comprehension과 비슷한 array comprehension을 만들 수 있습니다.

일단 편의를 위해 배열에 대해 여러 고차함수를 제공하는 lodash 라이브러리를 사용하겠습니다.

lodash는 여러 고차함수를 제공하지만 array comprehension을 구현하기 위해서는 다음 두 함수를 추가할 필요가 있습니다. lodash의 mixin() 함수를 이용해 다음과 같이 추가합니다. (참고로 이 함수들은 lodash-contrib에서 가져왔습니다.)

var concat = Array.prototype.concat;
_.mixin({
  // Concatenates one or more arrays given as arguments.  If given objects and
  // scalars as arguments `cat` will plop them down in place in the result
  // array.  If given an `arguments` object, `cat` will treat it like an array
  // and concatenate it likewise.
  cat: function() {
    return _.reduce(arguments, function(acc, elem) {
      if (_.isArguments(elem)) {
        return concat.call(acc, slice.call(elem));
      }
      else {
        return concat.call(acc, elem);
      }
    }, []);
  },

  // Maps a function over an array and concatenates all of the results.
  mapcat: function(array, fun) {
    return _.cat.apply(null, _.map(array, fun));
  },
});

여기서 cat() 함수는 여러 개의 배열을 인자로 받아 하나의 배열로 합쳐줍니다.

var s1 = _.cat([1,2,3], [4], [5,[6]]);
console.log(s2);
// [ 1, 2, 3, 4, 5, [ 6 ] ]

다음으로 mapcat() 함수는 배열의 각 원소 x에 대해 함수 f를 호출하여 결과로 리턴된 배열을 합쳐서 리턴합니다. 여기서 함수 f는 원소를 인자로 받아 새로운 배열을 리턴하는 함수입니다.

var s2 = _.mapcat([1,2,3], function (x) { return [x, x]; });
console.log(s2);
// [ 1, 1, 2, 2, 3, 3 ]

map(), filter(), mapcat() 함수만 있으면 Python의 list comprehension을 다음과 같이 기계적으로 만들어낼 수 있습니다.

// >>> [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
// [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]

var r1 = _.mapcat([1, 2, 3], function (x) {
  return _.map(_.filter([3, 1, 4], function (y) {
    return x !== y;
  }), function (y) {
      return [x,y]; // Use an array to create a tuple.
  });
});
console.log(r1);

규칙을 눈치 채셨나요? for가 한 번만 나오면 map() 함수로, for가 2번 이상 나오면 mapcat()을 사용하다가 가장 마지막에만 map()을 씁니다. 그리고 if 조건이 나오면 적절히 filter를 삽입해 주면 됩니다. (참고로 여기서 filter는 eager evaluation하기 때문에 불필요하게 두 번 루프를 돌아서 비효율적인 면이 있습니다. lazy evaluation하는 방법으로 성능 개선이 가능합니다만, 설명이 너무 복잡해져서 이 부분은 생략합니다.)

번역 규칙을 간단히 정리하면 다음과 같습니다.

* [x+1 for x in xs] ≡

_.map(xs, function (x) { return x+1; });

* [x+1 for x in xs if x != 0] ≡

_.map(_.filter(xs, function (x) { return x !== 0; }),
      function (x) { return x+1; });

* [(x, y) for x in xs for y in ys] =

_.mapcat(xs, function (x) {
  return _.map(ys, function (y) {
    return [x, y];
  });
});

다음은 array comprehension을 이용해 행렬을 transpose하는 코드입니다.

/*
>>> matrix = [
...     [1, 2, 3, 4],
...     [5, 6, 7, 8],
...     [9, 10, 11, 12],
... ]

>>> [[row[i] for row in matrix] for i in range(4)]
[[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]]
*/

var matrix = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]];
var r2 = _.mapcat(_.range(4), function (i) {
  return [_.map(matrix, function (row) {
    return row[i];
  })];
});
console.log(r2);