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

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

우리가 알고 있는 대게의 프로그래밍 언어는 만든 사람이 명확합니다. 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 자체에 별로 기여한 바는 없지만, 회의 참석하여 이런 모습들을 보며 많이 배우고 느꼈습니다. 한국에서 온 이름도 모르는 엔지니어한테도 모두들 친절하게 먼저 말을 걸고, 배려해주시는 모습에 감동받았고 나도 나중에 좋은 엔지니어가 되면 그렇게 해야지라고 생각했는데 아직 갈 길이 멀기만 하네요.

Y Combinator

요즘은 전산 전공자조차도 와이 컴비네이터(Y Combinator)가 Paul Graham이 설립한 실리콘밸리 스타트업 인큐베이터라고만 아는 경우가 많은 것 같습니다. 하지만 Y Combinator는 전산학에서 아주 중요한 개념입니다. 함수 언어, 특히 Lisp의 예찬론자인 Paul Graham도 여기서 회사 이름을 따왔습니다.

일단 combinator란 말은 Moses Schönfinkel와 Haskell Curry가 만든 Combinatory logic에서 왔습니다. Combinatory logic은 lambda calculus와 마찬가지로 전산학에서는 계산을 모델링하는 방법으로 사용되는데, 몇 가지 함수의 조합만으로 새로운 함수를 만들어내는 방법을 뜻합니다. 여기서 combinator는 free variable이 없는 함수라고 생각하면 됩니다. Combinatory logic에서는 다음과 같이 3개의 가장 기본적인 combinator를 정의합니다.

  • I combinator
var I = function (x) {
            return x;
        };
  • K combinator
var K = function (x) {
        return function(){
            return x;}
        };
  • S combinator
var S = function (x) {
           return function (y) {
               return function (z) {
                   return x(z)(y(z));
               }
           }
       };

(모든 combinator는 curried function으로 표현하였습니다.)

여기서 I combinator는 인자 그대로 리턴하는 identity 함수이고, K combinator는 항상 첫 번째 인자를 리턴하는 const 함수입니다. S combinator만 조금 복잡한 계산을 수행하는 것처럼 보입니다. Combinatory logic의 놀라운 점은 S와 K 단 2개의 combinator의 조합만으로 Turing machine이 계산 가능한 모든 계산을 할 수 있다는 점입니다. (I combinator는 S, K로 만들어 낼 수 있습니다.)

Y combinator는 fixed point combinator라고 불리는데, 재귀 함수를 만들어주는 combinator입니다. JavaScript 같이 재귀 함수를 직접 지원하는 언어만 사용하신 분은 이 개념이 다소 생소할 수 있습니다. 예를 들어, JavaScript로 팩토리얼을 구하는 함수 fac을 작성하면 다음과 같습니다. fac 함수 내에서 fac(n-1)을 호출하는 것을 확인할 수 있습니다.

function fac(n) {
    if (n === 1)
        return 1;
    else
        return n * fac(n - 1);
}

console.log(fac(5));
// 120

하지만 익명 함수(anonymous function)으로만 이 함수를 작성해야 한다면 어떨까요? 함수 내에서 자기 자신을 부를 수 있는 방법이 없기 때문에 더 이상 재귀 함수를 만들 수가 없게 됩니다.

var fac = function (n) {
    if (n === 1)
        return 1;
    else
        return n * ??(n - 1);
}

여기서 등장하는 함수가 Y combinator입니다. Y combinator를 이용하면 다음과 같이 fac 함수를 정의할 수 있습니다.

var fac = Y(function (fac) {
    return function (n) {
        return n === 1 ? n : n * fac(n - 1);
    };
});

JavaScript로 Y combinator를 다음과 같이 정의할 수 있습니다.

function Y(le) {
    return (function (f) {
        return f(f);
    }(function (f) {
        return le(function (x) {
            return f(f)(x);
        });
    }));
}

물론 S, K combinator만으로 모든 계산 가능한 함수를 만들어낼 수 있기 때문에 Y combinator를 S, K로 표현할 수도 있습니다.

Y = S S K (S (K (S S (S (S S K)))) K)

이 글은 Y combinator의 개념만 간단히 소개하는 것으로 마치고, 다음 글에서 fixed point의 개념 및 Y combinator 유도 과정에 대해 살펴보겠습니다.

타입 시스템

한 줄 광고: 보다 강력한 타입 시스템을 가진 언어를 찾으신다면, 하스켈 학교에서 하스켈을 공부해보시기 바랍니다!

타입 시스템(type system)은 프로그래밍 언어를 연구하는 학자들에게는 아주 중요한 연구 분야입니다. Curry–Howard Isomorphism에 따라 컴퓨터 프로그램과 수학적인 증명 사이에 직접적인 연관 관계가 있음에 밝혀졌기 때문입니다. 극단적인 예로, Coq의 경우, 프로그램을 작성하는 행위 그 자체가 해당 프로그램이 나타내는 정리를 증명하는 일이 됩니다.

하지만 일반적인 개발자 입장에서 타임 시스템은 애증의 대상입니다. 타입 시스템은 개발자가 올바른 프로그램을 작성하기 위한 도우미 역할을 해야 하지만, 반대로 개발자가 컴파일러를 위해 열심히 타임을 적어줘야 하는 경우가 더 많았기 때문입니다. 일례로, C++의 템플릿(template)이나 Java의 제네릭(Generic)을 사용해 보신 분들은 코드보다 타입 어노테이션이 더 길어지는 상황을 경험해 보셨을 거라 생각합니다.

많은 개발자들이 Java를 버리고, Python, Ruby 같은 동적 타이핑(dynamic typing)하는 스크립트 언어로 갈아탄 것도 이런 타입 시스템의 불편함에 기인한 바가 큽니다. 안타까운 점은 이런 불편함이 타입 시스템 자체의 한계에서 나온 것이 아니라, C++이나 Java 타입 시스템 구현의 문제임에도 불구하고 타입 시스템에 대해 부정적인 인식이 널리 퍼졌다는 사실입니다.

정적 타이핑과 동적 타이핑 논쟁은 그 자체로 사실 큰 의미가 없습니다. 흔히 정적 타이핑과 동적 타이핑으로 여러 프로그래밍 언어를 분류하지만, 실제로는 다양한 스펙트럼이 존재하기 때문입니다. 예를 들어, 정적 타이핑하는 Java 같은 언어에도 컴파일 타임이 아닌 런타임에만 확인 가능한 속성들이 존재하고, 이는 타임 시스템 설계 시 여러 트레이드오프를 고려해서 선택하는 사항이기 때문입니다. 또한 이론적으로 동적 타이핑이란 컴파일 타임에 타입이 하나뿐인 정적 타이핑 시스템으로 이해할 수 있습니다.

C# 같은 언어는 이런 경계를 더 흐리게 만듭니다. C# 4.0에 포함된 dynamic은 정적 타이핑하는 언어에 동적 타이핑을 포함시켰기 때문입니다. 예를 들어, 아래 코드는 스크립트 언어들과 마찬가지라 ExampleClassexampleMethod1가 실제로 존재하는지 컴파일 타임이 아닌 런타임 확인하게 됩니다.

    dynamic dynamic_ec = new ExampleClass();
    // The following line is not identified as an error by the
    // compiler, but it causes a run-time exception.
    dynamic_ec.exampleMethod1(10, 4);

정적 타이핑하는 언어를 사용할 때 가장 불편한 점 중 하나가 타입 어노테이션을 일일이 적어줘야 한다는 점인데, 현대적인 언어들은 타입 추론(type inference)를 통해 이런 불편함을 줄이기 위해 노력하고 있습니다. C#도 C# 3.0에 Implicitly typed local variables을 도입해서 아래와 같이 컴파일러가 타입을 바로 알 수 있으면 개발자가 굳이 타입을 적어주지 않아도 되게 바뀌었습니다.

// i is compiled as an int
var i = 5;

// s is compiled as a string
var s = "Hello";

// a is compiled as int[]
var a = new[] { 0, 1, 2 };

// expr is compiled as IEnumerable<Customer>
// or perhaps IQueryable<Customer>
var expr =
    from c in customers
    where c.City == "London"
    select c;

// anon is compiled as an anonymous type
var anon = new { Name = "Terry", Age = 34 };

// list is compiled as List<int>;
var list = new List<int>();

하지만 이런 노력에도 불구하고 타입 시스템에 대한 부정적인 의견은 더 있습니다. 대표적인 예로, 타입 시스템이 개발자의 표현의 자유를 제한한다는 비판이 있습니다. 실제로는 전혀 문제 없는 코드임에도 불구하고, 타임 시스템의 한계 때문에 문제가 있는 코드로 인식하고 거절하는 경우를 말합니다. 예를 들어, 함수 f와 인자 u, v를 받아서 uv를 인자로 함수 f를 각각 호출한 다음 결과를 튜플로 리턴하는 코드를 JavaScript로 작성하면 다음과 같습니다.

function applyTuple2(f, u, v)
{
  var ru = f(u);
  var rv = f(v);
  return [ru, rv];
}

그리고 다음과 같이 f에는 id 함수를, u, v는 각각 0과 ‘a’를 인자로 해서 applyTuple2 함수를 호출하면 결과는 예상대로 [0, ‘a’]가 리턴되게 됩니다.

function id(x)
{
  return x;
}

var i = 0;
var a = 'a';

var r2 = applyTuple2(id, i, a);

재미있는 사실은 비교적 간단해 보이는 이 코드를 C#으로 표현할 방법이 없다는 점입니다. 위 코드를 C#으로 그대로 옮기면 다음과 같습니다.

class MainClass
{
    public static Tuple<U, V> ApplyTuple2<T, U, V>(Func<T, T> f, U u, V v)
    {
        U ru = f(u);
        V rv = f(v);
        return new Tuple<U, V>(ru, rv);
    }

    public static T Id<T>(T x)
    {
        return x;
    }

    public static void Main(string[] args)
    {
        int i = 0;
        char a = 'a';

        var r2 = ApplyTuple2(Id, i, a);
    }
}

안타깝게도 이 코드는 컴파일이 안 됩니다. C# 타입 시스템의 한계 때문에 f 함수는 Func[int, int] 혹은 Func[char, char] 둘 중 하나만 될 수 있기 때문입니다. 이 경우 f 함수를 polymorphic하게 각각 u, v 인자에 대해 호출하고 싶은 게 개발자의 의도이지만, C# 타입 시스템에서 이 의도를 표현할 방법이 존재하지 않습니다.

그런데 이게 이론적으로 불가능한 건 아닙니다. 타임 시스템이 이런 요구사항을 표현하기 위해서는 Rank-2 Polymorphism을 지원해야 합니다. 안타깝지만, 대중에게 널리 알려진 프로그래밍 언어 중에 Rank-2 Polymorphism을 지원하는 언어는 Haskell밖에 없습니다.

소프트웨어 엔지니어링이라는 게 다 그렇지만, 결국 트레이드오프의 문제가 됩니다. 타입 시스템도 개발자의 편의, 컴파일 타임에 검증할 수 있는 속성의 범위, 구현의 복잡도, 성능 등 여러 요소를 고려해서 설계할 수밖에 없고 어떤 쪽에 무게를 두느냐에 따라서 Python, Ruby 같은 동적 타이핑 언어부터, C#, Java 같은 대중적인 정적 타이핑 언어, Scala, F#, Haskell 같은 강력한 타입 시스템을 가진 언어까지 여러 스펙트럼이 나오게 됩니다.

많은 개발자들이 Scala, F#, Haskell과 같은 강력한 타입 시스템을 가진 언어들을 경험해보지 않고 C++, C#, Java의 타입 시스템의 정적 타입 시스템의 한계라고 생각하는 것 같습니다. “정적 타이핑 vs 동적 타이핑”이라는 틀에서 벗어나서 좀 더 종합적인 관점에서 여러 타입 시스템을 비교해 볼 것을 권합니다.

프로그래밍 언어의 발전 방향

한 줄 광고: 여러 프로그래밍 언어에 지대한 영향을 미친 함수 언어의 끝판왕 하스켈을 공부하고 싶으신 분은 하스켈 학교에 방문하시기 바랍니다!

게임 코딩 스쿨에서는 “C# 바로 알기”, “JavaScript 바로 알기” 시리즈를 통해서 각 언어를 제대로 사용하는 방법에 대해 설명하고 있습니다. 수많은 언어 중에서 하필 두 언어를 고른 이유는 단순히 두 언어가 게임 프로그래밍에 많이 사용되기 때문만은 아닙니다. 두 언어를 선택한 또 다른 이유는 두 언어가 빠른 속도로 발전하고 있고, 여기서 배울 점이 많이 있기 때문입니다. 재미있는 사실은 서로 전혀 다른 배경과 목적으로 탄생한 두 언어가 놀랍게도 유사한 방향으로 발전하고 있다는 점입니다.

우리가 자주 사용하는 프로그래밍 언어로는 C, C++, Java, C#, Python, Ruby, JavaScript 등이 있습니다. 대부분의 주류 언어는 객체지향 프로그래밍 언어로 분류되고, 문법이나 용법, 표준 라이브러리 등에 차이는 있어도 큰 맥락에 프로그래밍을 하는 방법은 대동소이합니다. 가장 큰 차이라면 C#, Java와 같이 정적 타이핑을 하느냐, Python, Ruby, JavaScript처럼 동적 타이핑을 하느냐의 차이 정도만 있을 뿐입니다.

그런데 학계에서 프로그래밍 언어를 전공한 사람들은 이런 주류 언어를 연구하지 않습니다. 학계에서는 주로 연구하는 언어는 “함수형 언어”로 분류되는데, 함수형 언어 중에 대중적으로 잘 알려진 언어는 Lisp, Scheme, ML, OCaml, Clojure, F#, Scala, Haskell이 있습니다. 대중적으로 잘 알려지지 않았지만 학계에서 많이 사용하는 언어로는 Coq, Idris, Agda 같은 다소 특이한 언어들도 있습니다. 물론 이외에도 수많은 연구용 언어들이 존재합니다.

주류 언어와 함수 언어는 오랜 시간 공존하며 직간접적으로 많은 영향을 미쳐왔습니다. 일례로, Java가 나오면서 대중화된 가비지 콜렉션(garbage collection)은 함수 언어에서는 이미 Lisp 시절부터 존재한 오래된 기술입니다. 함수 언어는 주류 언어와 달리 immutable 타입을 강조하고, 기존 메모리를 갱신하는 대신 새로운 값을 만들어내는 방식으로 계산을 수행하므로, 일일이 메모리를 수동으로 할당하고 해제하는 것이 불가능했습니다. 이런 언어 특징 때문에 일찌감치 자동 메모리 관리 기술이 발달할 수밖에 없었습니다.

반대로, 메모리를 한 번 할당한 다음 할당된 메모리를 갱신하는 방법으로 계산을 수행하고, 성능을 강조했던 주류 언어는 상대적으로 가비지 콜렉션의 도입이 늦을 수밖에 없었습니다. 초기 절차형 언어 Fortran, Algol, C에서는 메모리 관리를 수동으로 하는 것이 크게 문제가 되지 않았으나, 점차 복잡해지는 소프트웨어를 작성하기 위한 객체지향 프로그래밍이 나오면서 수동 메모리 관리는 점점 부담이 됩니다. 다행히 해결책은 이미 함수 언어에서 수십 년 전에 고민한 가비지 콜렉션에 있었고, Java 이후 현재의 주류 프로그래밍 언어는 대부분 가비지 콜렉션을 채택하고 있습니다.

함수 언어가 주류 언어에 영향은 미친 또 다른 사례로는 Java와 C#에서 Generic으로 알려진 Parametric Polymorphism이 있습니다. 한국말로 다형성으로 번역하는 Polymorphism에는 Subtype Polymorphism, Parametric Polymorphism, Ad-hoc Polymorphism 등 여러 방식이 존재하는데, Java 5, C# 2 이전전의 객체지향 프로그래밍 언어는 Polymorphism이 곧 Subtype Polymorphism인 것처럼 용어를 사용했습니다.

Java 5 이전 버전의 Java를 사용해 보신 분들은 아시겠지만, Generic이 없으면 인자 타입만 다른 똑같은 함수를 여러 개 정의하거나, java.util.Arrayssort 메소드처럼 타입 정보 없이 가장 범용적인 타입인 Object[]를 처리하는 메소드를 작성하는 수밖에 없었습니다. 당씨 선과 마이크로소프트는 이런 불편함을 해결하기 위한 방법을 찾기 시작했고, 역시나 해결책은 이미 함수 언어에서 수십 년 전에 개발한 Parametric Polymorphism에 있었습니다.

이후에도 주류 언어는 끊임 없이 함수 언어의 개념들을 빌려오기 시작합니다. C#과 Java의 람다(lamba)가 또 다른 대표적인 예제입니다. 함수 언어의 가장 큰 특징은 함수가 first-order라는 점인데, 쉽게 말해 함수를 다른 타입과 마찬가지로 함수의 인자로 넘기거나 리턴 값으로 받을 수 있고, 변수에 저장할 수 있는 특징을 말합니다. JavaScript가 수많은 결점들에도 불구하고, 지금까지 살아남고 발전하고 있는 가장 큰 이유도 함수가 first-order라는 점을 꼽을 수 있습니다. 왜냐하면 first-class 함수로 할 수 있는 일이 상상을 초월할 정도로 많기 때문입니다.

서두에 JavaScript와 C#은 서로 전혀 다른 배경에서 탄생하고 발전하는 언어임에도 불구하고 발전 방향이 놀랍게도 유사하다고 말씀드렸습니다. 그 이유는 간단합니다. 두 언어 모두 함수 언어의 개념들을 차용해 오고 있기 때문입니다.

앞으로 주류 언어가 어떤 방향으로 발전할지 예측하는 일도 어렵지 않습니다. 함수 언어가 이룩한 성과 중에서도 아직 주류 언어가 가져오지 않은 게 무엇인지만 살펴보면 되기 때문입니다. 물론, 이미 지금 프로그래밍 언어 학계가 연구하고 있는 따끈따근한 연구 주제가 아닌 이미 10-20년 이상 시간을 가지고 검증한 기술이어야 합니다. 주류 언어는 수많은 대중을 상대하는 만큼 보수적일 수밖에 없기 때문입니다.

사실 변화는 이미 나타나고 있습니다. C# 3 LINQ, C# 5 async 함수, C# 6 null propagator, ES 7의 async 함수 등의 공통점이 무엇일까요? 정답은 Monad입니다. LINQ, async 함수, null propagator 등은 전혀 다른 기능처럼 보이지만, Monad라는 일종의 수학적인 구조를 따르고 있습니다. LINQ는 List Monad의 다른 이름, async 함수는 Future Monad의 다른 이름, null propagator는 Maybe Monad의 다른 이름일 뿐입니다.

임의의 Monad를 직접 만들어 쓸 수 있는 함수 언어와 달리, 주류 프로그래밍 언어는 이 중에서 유용성이 검증된 일부 Monad를 언어 기능으로 제공하는 단계라고 생각할 수 있습니다. 앞으로 한동안 주류 프로그래밍 언어는 여러 Monad를 가져오는 작업을 할 것으로 보입니다. 이 단계를 지나고 나면, Haskell의 do 표기법이나 F#의 computation expression처럼 언어 사용자가 직접 임의의 Monad를 정의할 수 있는 방법을 제공할 가능성도 있습니다.

저는 지금이 변화의 시기라고 생각합니다. 소프트웨어는 계속 복잡해지고 있고, 과거 절차형 언어에서 객체지향 언어로 넘어간 것과 마찬가지로 지금은 함수 언어가 이런 복잡성을 해결할 수 있는 대안으로 떠오르고 있습니다. Clean Code의 저자이며 객체지향 디자인 패턴과 원리를 강조한 Robert Martin이 지금은 Functional Programming을 전도하는 것을 보며 무엇을 느끼시나요?

코루틴(Coroutine) 이해하기

Unity 프로그래밍에서 중요한 개념 중 하나는 코루틴(Coroutine)입니다. 게임 프로그래밍은 수많은 게임 오브젝트들의 상호 작용으로 표현될수 밖에 없기 때문에 어떤 식으로는 동시성(concurrency)을 표현하는 것이 중요합니다. Unity는 .NET을 사용함에도 불구하고 멀티쓰레드가 아닌 코루틴으로 동시성을 표현하는데, 이는 멀티쓰레드 프로그래밍이 버그 없는 코드를 작성하기 어렵기로 악명 높기 때문입니다.

일단 코루틴이 무엇인지 용어 정의부터 시작하겠습니다. 코루틴은 서브루틴(C#의 메소드에 해당)을 다음 두 축으로 확장한 강력한 서브루틴입니다.

  • 여러 개의 입구(entry point)를 허용하여 실행 재개가 가능
  • 멈춤 시 돌아갈 위치 지정

일반적인 서브루틴은 호출될 때마다 메소드의 처음부터 실행을 시작하고 리턴될 때 항상 호출자(caller)로 돌아가는 반면, 코루틴은 이전 상태를 기억하고 있어서 호출 시 이전 호출에서 멈춘 위치에서부터 다시 실행을 재개하게 되고, 멈출 때는 호출자로 돌아가는 대신 돌아갈 위치를 지정할 수 있습니다.

Unity 프로그래밍에서 코루틴이 중요한 이유는 Unity는 코루틴을 협력형 멀티태스킹(cooperative multitasking)을 구현하는 용도로 사용하기 때문입니다. 협력형 멀티태스킹은 일종의 시분할(time-sharing) 방식으로 여러 task가 하나의 CPU를 나눠쓰는 방식인데, 선점형 멀티태스킹(preemptive multitasking)과 달리 운영체제의 개입 없이 각 task가 독점적으로 CPU를 사용하고 사용이 끝나면 자발적으로 양보하는 방식입니다. 이 방식의 장점은 리소스를 사용하는 도중에 강제로 CPU를 뺏기는 일이 없으므로, 크리티컬 섹션(critical section)을 보호하기 위한 락(lock)이나 세마포어(semaphore) 등 동기화 수단이 필요 없다는 점입니다.

그럼 코루틴으로 협력형 멀티태스킹을 어떻게 구현하는 걸까요? 이해를 돕기 위해 큐에 아이템을 생성하여 집어넣는 produce task와 큐에서 아이템을 꺼내와서 사용하는 consume task를 의사코드(pseudo code)로 구현해 보겠습니다. 이 예제는 위키피디아의 Coroutine 페이지를 참고하였습니다.

var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume

produce task는 q가 가득 차지 않았으면 새로운 아이템을 생성하여 q에 집어 넣습니다. q가 가득 찼으면 더 이상 할 수 있는 일이 없으므로 consume task에 양보합니다.

coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

consume task는 q가 가득 차 있으면 q에서 아이템을 꺼내서 사용합니다. 꺼낼 아이템이 없으면 더 이상 할 수 있는 일이 없으므로 produce task에 양보합니다.

위 코드에서 주목할 부분은 별도의 스케쥴러가 없다는 점입니다. produce task와 consume task 모두 다음 task를 명시적으로 지정하고 있기 때문에 제3자가 개입하여 다음 task를 지정할 필요가 없습니다. “협력형 멀티태스킹”이란 말도 이런 특징에서 기인하였습니다.

여기까지 이해했으면 의문점이 생기게 됩니다. 코루틴으로 협력형 멀티태스킹을 구현할 수 있다는 건 알겠는데, 정작 Unity게임 프로그래밍에 사용하는 C#이란 언어는 코루틴을 제공하지 않기 때문입니다. C#이 코루틴이 없는데, 어떻게 Unity는 코루틴으로 협력형 멀티태스팅을 구현하는 걸까요? 정답은 C#의 이터레이터(iterator)를 사용하는 것입니다.

C# 개발자라면 yield 키워드로 대표되는 C#의 이터레이터를 사용해 본 경험이 있을 겁니다. C# 이터레이터는 프로그래머가 IEnumerableIEnumerator를 쉽게 구현할 수 있게 프로그래밍 언어가 제공하는 기능입니다. 파이썬, EcmaScript 6 등 다른 프로그래밍 언어에서는 제너레이터(generator)로 알려져 있는 기능이기도 합니다. 이해를 돕기 위해 다시 위키피디아 Generator 페이지에 나와있는 C# 이터레이터 예를 하나 살펴보겠습니다.

public class CityCollection : IEnumerable<string> {
    public IEnumerator<string> GetEnumerator() {
        yield return "New York";
        yield return "Paris";
        yield return "London";
    }
}

위 예제의 GetEnumerator 함수는 IEnumerator 타입을 리턴하는데, 이 IEnumeratorMoveNext() 메소드는 호출될 때마다 “New York”, “Paris”, “London”을 차례대로 리턴하게 됩니다. 다시 말해, 일반적인 서브루틴과 달리 위 메소드는 yield return이 값을 리턴하고 이 위치를 기억해 두었다가 다음 호출 시에는 그 다음 위치부터 실행을 재게합니다.

입구가 여러 개이고 실행이 멈춘 지점을 기억했다가 다시 재개한다는 점은 앞서 설명한 코루틴에 대한 설명과 무척 닮았습니다. 물론 코루틴은 리턴될 때 다음 실행할 위치를 명시적으로 지정할 수 있고, 제너레이터는 반드시 호출자로 돌아간다는 차이점도 있습니다. 이런 유사점 때문에 제너레이터를 세미 코루틴(semi coroutine)이라고 부르기도 합니다. 한국말로는 반쪽 코루틴 혹은 약한 코루틴으로 표현할 수도 있겠네요.

여기서 제너레이터-코루틴-협력형 멀티태스킹의 연결 고리가 생기게 됩니다. 제너레이터가 있으면 코루틴을 만들어낼 수 있기 때문입니다. 앞서 살펴본 produce, consume 코루틴의 예를 제너레이터로 다시 구현해 보겠습니다.

generator produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield consume
generator consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield produce

코루틴 예와 달라진 부분은 yield to consumeyield to produce가 각각 yield consumeyield produce로 바뀌었다는 점입니다. 고작 to라는 단어 하나가 빠진 것 같지만, 실제로는 큰 차이가 있습니다. 코루틴의 yield to consumeconsume 코루틴 실행을 재개하라는 뜻이고, yield consume는 이 제너레이터를 호출한 호출자로 리턴되는 값이 consume이라는 뜻입니다.

yield consume의 의미는 함수가 first-class인 자바스크립트나 함수형 언어를 생각하면 쉽게 이해됩니다. consume이라는 제너레이터를 함수 값으로 생각하고 단순히 리턴하는 것입니다. 따라서 consume을 리턴한다고 해서 자동으로 consume 제너레이터가 실행되는 것이 아닙니다. 제너레이터를 코루틴처럼 사용하기 위해서는 다음과 같이 다음으로 실행할 task를 지정해 줄 스케쥴러가 필요하게 됩니다.

subroutine dispatcher
    var d := new dictionary(generator → iterator)
    d[produce] := start produce
    d[consume] := start consume
    var current := produce
    loop
        current := next d[current]

위 코드의 dispatcher는 스케쥴러 역할을 합니다. 일단 dispatcher가 먼저 실행되어 produceconsume 제너레이터를 만들고 루프를 돌면서 제너레이터가 리턴하는 값을 이용해 다음 task를 정하는 방식입니다. 위 예제의 경우 current의 값이 produceconsume로 계속 바뀌면서 번갈아 실행되게 되므로 앞서 살펴본 코루틴의 예제와 마찬가지로 협력형 멀티태스킹이 구현됩니다.

꼭 각 제너레이터가 다음 task를 지정하는 방식이 아니어도 괜찮습니다. 스케쥴러가 전체 task 목록을 가지고 스케쥴링 하는 방식이 되면 일반적인 운영체제의 스케쥴러와 마찬가지로 다양한 스케쥴링 알고리즘을 적용할 수도 있습니다. 예를 들어 yield 문에 다음으로 실행할 제너레이터를 지정하는 대신에 해당 task가 다시 스케쥴링될 시간을 지정할 수도 있습니다. 그리고 이 방식이 바로 Unity가 협력형 멀티태스킹을 구현하는 방법입니다.

IEnumerator Fade() {
    for (float f = 1f; f >= 0; f -= 0.1f) {
        Color c = renderer.material.color;
        c.a = f;
        renderer.material.color = c;
        yield return new WaitForSeconds(.1f);
    }
}

위 코드는 yield 문이 있는 C#의 이터레이터이고 따라서 리턴 타입도 IEnumerator입니다. 하지만 이 코드가 실제로 의미하는 바는 여러 개의 입구가 존재하는 제너레이터이고 yield를 통해 해당 task가 다음에 스케쥴링될 시간을 지정하고 있습니다. Unity 내부에는 이런 제너레이터의 목록을 가지고 있는 스케쥴러가 있어서 앞서 살펴본 방식과 동일한 방식으로 스케쥴링을 해주고 있습니다. MonoBehaviour.StartCoroutine 메소드가 Unity 스케쥴러에 코루틴을 추가하는 방법입니다.

정리하면

  • C#의 이터레이터는 제너레이터이다.
  • 제너레이터는 세미 코루틴이라고도 불리며 스케쥴러의 도움을 받으면 코루틴을 구현할 수 있다.
  • 코루틴을 이용하면 협력형 멀티태스킹이 가능하다.

제너레이터로 코루틴 및 협력형 멀티태스킹을 구현하는 방식은 Unity뿐만 아니라 여러 프레임워크나 라이브러가 사용해 온 일반적인 방법입니다. C++ 대신 쉬운 C#으로 코딩하라는 모토를 가지고 나온 Unity 입장에서 프로그래머가 실수하기 쉬운 멀티쓰레드 프로그래밍보다는 코루틴을 이용한 협력형 멀티태스킹을 강조한 것도 이해가 되는 부분입니다.

하지만 제너레이터를 이용한 코루틴 구현은 어느 정도 “해킹”스러운 면이 있고, IEnumerator를 본래 의미인 여러 collection 타입에 대한 단일 인터페이스가 아닌 전혀 다른 의미로 사용하면서 프로그래머들에게 혼란을 초래하는 면이 있습니다. 게다가 이후 .NET의 Task Parallel Library, C# 5의 async 키워드 등이 나오고 .NET의 멀티쓰레드 프로그래밍 모델이 상당한 발전을 하면서 Unity의 협력형 멀티태스킹 방식의 장점이 상당 부분 희석되었다고 생각합니다. 이 부분은 다음에 다시 한 번 다루도록 하겠습니다.