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

오늘 임백준님이 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);

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 유도 과정에 대해 살펴보겠습니다.

JavaScript 함수: 스코핑, 클로저

오늘은 JavaScript 함수의 두 가지 대표적인 특징인 스코핑 규칙과 클로저에 대해 설명합니다.

스코핑(scoping)

JavaScript는 정적 스코핑(static/lexical scoping)하는 언어입니다. 초기 Lisp을 제외하고 동적 스코핑(dynamic scoping)하는 언어는 거의 없기 때문에 “정적” 스코핑이란 말은 크게 의미가 없습니다. 다만, 다른 언어와 달리 블록 스코핑(blocking scoping)이 아닌 함수 스코핑(function scoping)이라는 점이 특이합니다.

예를 들어, 아래 foo() 함수 호출은 x의 값을 리턴하는데, x 값은 1이 아닌 2가 리턴됩니다. 블록 안에서 새로 변수 x를 선언하고 2를 할당했지만, 자바스크립트는 함수 스코핑이기 때문에 함수 foo()x 변수는 단 하나만 존재합니다. 따라서 var x=2는 새로운 변수를 선언이 아니라 단순히 x=2라는 할당문으로 취급됩니다.

function foo() {
    var x = 1;
    {
        var x = 2;
    }
    return x;
}

var x = foo();
console.log(x);
// 2

함수 스코핑이 블록 스코핑에 비해 가지는 장점은 없습니다. 의도한 설계라기 보다는 초기 프로그래밍 언어 설계 오류라고 보는 편이 맞습니다. 실제로 ES6에서는 이 문제를 바로 잡기 위해 let 키워드를 이용한 블록 스코핑을 새로 도입하였습니다. 아래 foo_() 함수는 var 키워드를 let으로 바꾸었을 뿐인데, 1이 리턴되는 것을 확인할 수 있습니다.

function foo_() {
    let x = 1;
    {
        let x = 2;
    }
    return x;
}

var x_ = foo_();
console.log(x_);
// 1

ES6는 이전 버전 JavaScript와 호환성 때문에 varlet 키워드를 모두 허용하지만, 새로 작성되는 코드는 모두 let으로 작성하는 것이 좋습니다.

클로저

JavaScript 함수는 1등 시민(first-class citizen)이기 때문에 string이나 number와 마찬가지로 함수의 인자로 넘기거나, 함수의 리턴값으로 돌려주거나, 변수에 저장하는 등의 일이 가능합니다. 따라서 함수 안에서 numberstring을 생성하고 사용할 수 있는 것처럼 함수 안에서 새로운 함수를 선언하고 사용하는 것도 가능합니다.

function makeAdder() {
    function add(a, b) {
        return a + b;
    }
    return add;
}

var adder = makeAdder();
console.log(adder(1, 2));
// 3

위의 makeAdder() 함수를 호출하면 새로운 함수 add()를 리턴하는데, 이 함수는 인자 a, b를 받아서 합을 리턴하는 함수입니다. 호출 결과를 변수 adder에 저장하면 adder(1,2)처럼 함수 호출도 가능함을 확인할 수 있습니다. 이 경우, 함수 add()makeAdder() 안에 선언되어 있기 때문에 함수 밖에서 접근할 수 없다는 점을 제외하면 최상단에 정의한 함수와 다를 바가 없습니다.

JavaScript는 여기서 한발 더 나아갑니다. 아래 makeAdder() 함수는 인자 a를 받아서 a를 더해주는 함수 addA를 리턴합니다. 여기서 aaddA()에 선언된 변수가 아니라 makeAdder()에 선언된 변수라는 점이 특별합니다. JavaScript 함수는 해당 함수에 참조하고자 하는 변수가 없으면 여기서 포기하지 않고 바깥 함수(enclosing function)들에 있는 변수를 찾는 것입니다.

function makeAdder(a) {
    function addA(b) {
        return a + b;
    }
    return addA;
}

var add1 = makeAdder(1);
console.log(add1(2));
// 3

따라서 makeAdder(1)이 리턴하는 함수는 인자 b를 받아서 1을 더해주는 함수가 됩니다. addA()에서 참조한 변수 amakeAdder() 함수가 리턴된 후에도 addA()에서 사용할 수 있기 때문에 가비지 콜렉션되지 않도록 JavaScript 런타임에 처리를 해줍니다.

우리는 보통 함수를 코드로만 생각합니다. 데이터는 함수 호출 시 인자로 넘기는 것이고 함수 자체는 순수한 코드라고 생각하는 경향이 있습니다. 하지만 위 예에서 볼 수 있듯이 JavaScript 함수는 단순히 코드가 아니라 코드+데이터입니다. addA() 함수는 덧셈을 하는 코드도 가지고 있지만, a의 값인 데이터도 가지고 있기 때문입니다. 이렇게 코드와 데이터를 모두 가지고 있는 함수를 클로저(closure)라고 부릅니다.

클로저가 강력한 이유는 “코드+데이터 = 프로그램”이기 때문입니다. 즉, 클로저만 있으면 모든 프로그램을 표현할 수 있다는 뜻입니다. 오브젝트도 마찬가지입니다. “오브젝트 = 코드+데이터”이고, “코드+데이터 = 클로저”이므로 “오브젝트 = 클로저”라는 공식이 성립합니다.

실제로 JavaScript에서는 클로저를 이용해 오브젝트를 만드는 일이 흔합니다. 클로저를 이용해 counter 오브젝트를 만들면 다음과 같습니다. (이 코드는 MDN 클로저 페이지에서 차용했습니다.) changeBy(), increment(), decrement(), value() 함수 모두 privateCounter 변수에 접근하는 클로저임을 확인할 수 있습니다.

var counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  };
})();

변화 예측

유지보수하기 좋은 코드를 만들려면 변경 사항에 대해 어느 정도 예측이 가능해야 합니다. 물론 이해하기 쉬운 코드를 작성하면 상대적으로 어떤 변화든 쉽게 대응할 수 있는 것이 사실이지만, 모든 종류의 변경 사항에 유연하게 대처할 수 있는 코드를 미리 작성하는 건 사실상 불가능하고 또 시간 낭비일 수도 있기 때문입니다.

리팩토링에서 코드 스멜이라고 이야기하는 것 중에 하나가 타입 혹은 enum 값을 체크하는 조건문이 반복되는 것입니다. Replace Conditional with Polymorphism은 이런 코드를 리팩토링해서 각 타입을 서브클래스를 만들고 다형성으로 해결하라고 이야기합니다.

예를 들어, 다음 코드는 _type에 따라 getSpeed() 메소드의 행동이 달라집니다.

class Bird {
   ...
   double getSpeed() {
       switch (_type) {
           case EUROPEAN:
              return getBaseSpeed();
           case AFRICAN:
              return getBaseSpeed() - getLoadFactor() * _numberOfCoconuts;
           case NORWEGIAN_BLUE:
              return (_isNailed) ? 0 : getBaseSpeed(_voltage);
       }
       throw new RuntimeException ("Should be unreachable");
   }
}

객체지향 프로그래밍의 일반적인 조언은 Bird 클래스의 getSpeed() 메소드를 abstract로 만들고, EuropeanBird, AmericanBird, NorwegianBlueBird 클래스를 만들어서 각각 getSpeed() 메소드를 오버라이드하라는 겁니다.

이렇게 했을 때 뭐가 더 좋아질까요? 일단 각 Bird 고유의 코드는 해당 클래스에 캡슐화(encapsulation) 된다는 장점이 있습니다. AmericanBird의 속도를 계산 방법이 바뀌었을 때 다른 코드에 영향을 주지 않고 구현을 변경할 수 있습니다. 또핸 새로운 Bird 종류가 추가되었을 때도 기존 Bird 구현을 수정하지 않고 추가할 수 있다는 장점이 있습니다.

여기서 가정한 변화의 방향은 Bird의 추가 혹은 특정 Bird의 구현 변경입니다. 하지만 변화의 방향이 다르면 예상과 달랐다면 어떻게 될까요?

Bird가 추가되는 대신 각 Bird에 계속 새로운 속성 혹은 행동이 추가되어야 한다면? 더 이상 변경 사항이 하나의 Bird에 국한되지 않고 모든 Bird 클래스를 수정해야 하는 상황이 됩니다. 예를 들어, 전반적인 속도 계산 알고리즘이 변경되어 모든 BirdgetSpeed() 메소드에 수정이 필요하다면, 오히려 리팩토링 전의 코드가 더 캡슐화가 잘 된 코드입니다. getSpeed() 메소드 하나만 수정하면 해결할 수 있고, 각 Bird의 속도 구현을 한 눈에 파악할 수 있기 때문입니다. 따라서 무조건 다형성을 추구할 것이 아니라 처음 Bird 클래스를 설계할 때 어떤 변경 사항이 많을지 예측을 하고 적절한 방식으로 구조를 잡아야 합니다.

Bird 추가나 특정 Bird 알고리즘의 수정이 동전의 앞면, 메소드 추가나 전체 Bird 알고리즘 수정이 동전이 뒷면이라면 객체지향 프로그래밍은 동전의 앞면에 베팅을 하고 있습니다. 하지만 우리가 이미 동전이 뒷면이 나올 확률이 더 높다고 예측을 했다면 이 경우 객체지향 방법론을 무조건적으로 적용하는 건 정답이 아니게 됩니다.

함수 언어는 보통 반대 방향으로 베팅을 합니다. ADT(Algebraic Data Type)의 Sum Type과 패턴 매칭은 우리가 코드 스멜이라고 생각하는 타입에 따른 조건문 사용을 오히려 장려합니다. 객체지향 프로그래밍과 달리 동전의 뒷면에 베팅을 하기 때문입니다. 마찬가지로 우리가 동전의 앞면이 나올 확률이 더 높다는 사실을 알고 있으면, 함수 프로그래밍을 적용하는 건 정답이 아니게 됩니다.

한 언어가 두 마리 토끼를 모두 잡는 것은 쉽지 않습니다. Philip Wadler 교수가 Expression Problem으로 이름 붙인 문제도 “프로그래밍 언어가 이런 두 가지 변화의 방향에 다 대처할 수 있는가?”입니다. 멀티 메소드나 헤스켈의 타입 클래스(Type Class) 등이 해법으로 나왔지만, 두 가지 모두 우리가 일반적으로 사용하는 프로그래밍 언어에 존재하지 않는 기능이므로 우리는 여전히 변화의 방향을 예측하고 베팅을 해야만 합니다.

객체지향 프로그래밍 원리나 리팩토링을 가르칠 때 가장 어려운 것도 내용이 아니라 맥락에 있습니다. 생각 없이 따르기만 하면 되는 프로토콜은 존재하지 않기 때문입니다. 같은 조언이라도 득이 될 때가 있고 실이 될 때가 있고, 결국 상황에 따라 판단이 필요합니다. 맥락 없이 “switch나 if로 타입 체크하는 코드는 무조건 나빠”라고 이야기하는 사람이 있다면 왜 그런 판단을 내리는지 물어보고 그 이유가 정말로 합당한지 한 번 더 고민해 보시기 바랍니다.

“조언자의 딜레마”라는 말이 있습니다. 세상에 좋은 조언을 널렸지만, 결국 어떤 조언을 취사선택할지의 문제는 나에게 있기 때문입니다. 디자인 패턴, 클린 코드, 리팩토링 등을 아무리 읽고 적용해도 결국 스스로 판단 없이 기계적으로 적용해서는 좋은 결과를 얻기 어렵습니다. 더 중요한 건 스스로 생각하는 훈련입니다.변화 예측