전역 변수/지역 변수

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.

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

Advertisements

리팩토링

아래 코드는 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);

왜 변수가 나쁜가?

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

일단 “전역 변수(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페이지에 가서야 가르치는 이유에 대해서도 생각해 볼 필요가 있습니다.

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

변화 예측

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

리팩토링에서 코드 스멜이라고 이야기하는 것 중에 하나가 타입 혹은 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로 타입 체크하는 코드는 무조건 나빠”라고 이야기하는 사람이 있다면 왜 그런 판단을 내리는지 물어보고 그 이유가 정말로 합당한지 한 번 더 고민해 보시기 바랍니다.

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

코드 주석

ZDNET에 올라온 나쁜 프로그래머가 되는 18가지 방법을 보면서 느낀 점을 몇 가지 적어봅니다. 이 글은 무려 18가지 방법에 대해 이야기하고 있는데, 일단 코드 주석에 대한 부분만 제 의견을 적어볼까 합니다.

9. 소스코드가 주석 하나 없이 깨끗하다

항상 주석 같이 읽기 쉬운 소스코드를 주장하면 주석 하나 없이 깨끗하게 코딩을 한다. 하지만 후배들은 주석이 없으면 이해하기 어렵다고 불평이다. 하지만 프로젝트 일정이 항상 너무 촉박해서 소스코드에 주석을 적을 시간이 없다.

보통 이런 상황은 주석이 없어서 코드가 이해하기 힘든 것이 아니라 코드가 이해하기 힘드니깐 주석이라도 달아서 설명을 해달라는 뜻입니다. 이런 불평이 나오면 주석을 쓸 게 아니라 코드를 리팩토링하거나 제대로 작성해야 하는 게 먼저입니다.

프로그래밍을 하다 주석이 필요하다고 느끼면 주석을 적기 전에 2번, 3번 다시 생각을 해야 합니다. 정말 주석을 달아야만 이해할 수 있는 코드인지, 주석이 아니라 코드에 작성자의 의도를 표현할 수 없는지 등을 고민하다 보면 대게의 경우 코드를 좀 더 잘 작성할 수 있는 방법을 찾게 됩니다.

주석은 코드가 아니기 때문에 시간이 지나면 코드와 주석이 따로 노는 현상이 빈번히 발생합니다. 변경이 잦은 코드일수록 주석은 빠르게 쓸모 없어지고, 코드를 읽을 때 오히려 혼란만 초라하게 되는 경우가 더 많습니다. 위 글 필자의 말처럼 프로젝트 일정이 항상 촉박하고 바쁜데, 코드뿐만 아니라 주석까지 유지보수할 개발자는 흔치 않기 때문입니다.

좋은 코드를 짜는 것도 어렵지만 좋은 주석을 다는 건 더 어렵습니다. 저는 코드 리뷰하면서 좋은 주석을 본 적이 거의 없습니다. 코드를 왜 이렇게 이상하게 짤 수밖에 없었는지 변명하거나, 코드에 이미 다 나와 있는 내용을 중복으로 기술하는 주석이 대부분입니다. 사실 이 정도도 글을 잘 쓰는 개발자나 할 수 있고, 대부분의 개발자는 함수 이름을 그대로 반복하는 수준의 주석만 씁니다. 예를 들어 함수 이름이 readFile이면 주석에 “파일을 읽는다”라고 쓰는 거죠. 그래서 저는 주석 쓸 시간에 코드를 좀 더 읽기 쉽게 짜라고 조언합니다.

물론 주석과 문서화는 다른 이야기입니다. 비전 문서, 아키텍처 문서, 설계 문서, API 문서 등 명확히 목적과 이유가 있는 문서들이 존재합니다. 하지만 우리가 흔히 “코멘트”라고 이야기하는 코드 주석은 명확한 목적성을 갖는 문서가 아니라 잘못을 변명 혹은 반성하는 경위서에 가깝습니다.

추가로 서두에 인용한 글처럼 소프트웨어 엔지니어링, 개발 프로세스 등에 대한 글을 쓸 때 “코드에 주석이 없으면 나쁜 개발자가 된다”는 식으로 결론만 이야기하는 건 무의미하다고 생각합니다. 이런 종류의 문제는 원래 정답이 없고 환경과 상황에 따라 적당한 수준을 찾는 과정이기 때문입니다.

코드 주석에 대해서 결론은 이미 정해져 있습니다. 필요한 만큼 적당한 수준으로 잘 적어야 잘하는 거죠. 이런 주제의 글을 쓰려면 여러 사례나, 본인의 경험담 등을 통해 그 뻔한 결론에 도달하는 과정을 보여줘야 합니다. 단순히 주석이 없으면 나쁘다는 글을 읽고 개발자들이 무엇을 배울 수 있는지 모르겠습니다.

관심사의 분리(Separation of Concerns)

게임 개발뿐만 아니라 모든 소프트웨어 개발의 핵심은 복잡성을 극복하는 것입니다. 프로그램은 작은 프로그램의 조합으로 만들어지는데, 다른 엔지니어링과 달리 이런 조합에 물리적인 제약이 존재하지 않기 때문에 훨씬 쉽게 복잡해지는 경향이 있습니다. 예를 들어, 초고층 건물이나 우주 비행선은 물리 법칙의 제약 때문에 더 복잡해지기 어렵지만, 소프트웨어는 이런 제약이 없습니다.

소프트웨어가 복잡해지면 가장 먼저 병목이 되는 건 사람입니다. 인간의 머리는 한 번에 생각할 수 있는 양에 한계가 있기 때문입니다. 인지 심리학자인 George A. Miller은 The Magical Number Seven, Plus or Minus Two라는 논문에서 인간은 아무리 똑똑해도 한 번에 처리할 수 있는 정보가 7개 정도(혹은 1,2개 더)로 제한되어 있다고 말합니다.

소프트웨어 엔지니어링에서 강조하는 원리 중 하나는 관심사의 분리(Separation of Concerns)입니다. 여러 가지를 동시에 신경 쓰면 복잡하니깐, 각각을 따로 분리해서 생각하자는 이야기입니다. Concern을 보통 관심사로 번역하고 있지만, 복잡성의 관점에서는 걱정거리로 생각할 수도 있습니다.

그런데 걱정거리는 쉽게 늘어납니다. 예를 들어, 게임 출시 전에 튜토리얼 기능을 추가한다고 합시다. 튜토리얼이라는 게 게임 전반에 걸쳐서 게임 진행 방법을 설명해야 하기 때문에, 기존 코드 여기 저기에서 Tutorial.IsTutorialMode()를 확인하고 true이면 튜토리얼 코드를 진행하고 false이면 기존 코드를 진행하는 코드가 필요하게 됩니다. 프로그램 전반에 걱정거리가 하나 추가된 셈입니다.

서버 코드도 마찬가지입니다. 요즘 출시되는 대부분의 게임은 업적 기능이 있는데, 업적 확인도 튜토리얼과 마찬가지로 하나의 기능이라기 보다는 서버 코드 전반에 걸쳐서 삽입되기 때문입니다. 플레이어가 친구를 3명 맺었으면 보상을 주는 업적이 있다고 하면, 친구 맺는 코드 입장에서는 “업적 확인”이라는 걱정거리가 하나 늘어난 셈입니다.

말하듯이 코딩하라가 생각처럼 쉽지 않은 이유도 마찬가지입니다. 아래 코드는 간단합니다. 유저 등록을 위해 필요한 절차를 말하듯이 나열하기만 하면 코딩이 끝나기 때문입니다.

function registerUser()
{
    var request = receiveRequest();
    validateRequest(request);
    canonicalizeEmail(request);
    updateDbFromRequest(request);
    sendEmail(request.Email);
    return sendResponse("Success");
}

그런데 걱정거리는 쉽게 늘어납니다. 위 코드에 예외 처리를 넣으면 어떻게 될까요? 각 함수마다 리턴 값을 확인하거나 try/catch 블록을 주렁주렁 달아줘야 합니다. 위 코드가 성능 때문에 비동기로 작성되어야 한다면 어떨까요? node.js 스타일이라면 다음 할 일을 일일이 콜백을 통해서 넘겨줘야 합니다. 위 코드가 C++로 작성되었다면 어떨까요? 이제 언제 메모리를 할당하고, 언제 해제할지 일일이 신경써야만 프로그램이 제대로 동작합니다. 인자를 넘기는 방식은 어떻게 해야 하나요? call-by-value, call-by-reference? sendEmail이 멀티쓰레드 접근에 안전하지 않아서 락을 잡아줘야 한다면? 여기에 매 100번째 등록 유저에게는 선물을 주는 이벤트를 진행한다면?

  • 기본 로직
  • 예외 처리
  • 비동기 프로그래밍
  • 메모리 할당/해제
  • 인자 전달 방식
  • 락(lock)
  • 이벤트

이렇게 간단한 코드에서도 우린 이미 인간의 인지 능력의 한계인 7개의 걱정거리에 도달했습니다. 이것보다 더 복잡해지면 대부분의 개발자는 더 이상 자기가 작성한 프로그램이 대체 어떻게 해서 돌아가는지 이해하지 못하게 됩니다. 하나의 걱정거리가 추가되면 다른 걱정거리 하나가 머리 속에서 지워지기 때문에 어디선가 문제가 생길 수밖에 없는 상태가 됩니다.

프로그래밍을 잘한다는 건 이런 수많은 걱정거리를 한 번에 처리하는 능력이 아니라, 코드에서 여러 걱정거리를 분리해 낼 수 있는 능력입니다. 우리가 알고 있는 수많은 객체지향 프로그래밍 원리, 디자인 패턴 등도 결국은 관심사 혹은 걱정거리를 분리하고 한 번에 하나씩만 생각하자는 이야기에 불과합니다. 소프트웨어 엔지니어링이란 인간의 인지 능력의 한계를 극복하기 위한 체계적인 방법론이라고 생각할 수 있습니다. 프로그래밍 언어 또한 인간의 인지 능력의 한계 내에서 복잡성을 극복하기 위한 다양한 수단을 제공하는 도구라고 생각할 수 있습니다. 프로그래밍이란 결국 복잡한 문제를 작은 문제로 나눠서 풀고, 다시 재조합하는 과정인 셈입니다.

협찬: SKP 유태원님 (아이스 까페모카) 감사합니다.

말하듯이 코딩하라

코드 리뷰하면서 자주 있었던 상황 중 하나가 실제로 작성한 코드와 코드 설명이 전혀 다른 경우입니다. 물론 전혀 엉뚱한 코드를 작성했다는 의미는 아닙니다. 최소한의 테스트 및 동작 확인은 하고 리뷰를 요청하기 때문에 코드 자체는 의도한 대로 동작합니다. 다만, 코드를 읽는 사람에게 코드의 의도를 제대로 전달하지 못하는 게 문제입니다. 이 문제는 코드를 설명하는 추상화 수준과 실제로 코드가 작성된 추상화 수준이 전혀 다르기 때문에 발생합니다.

이메일로 유저를 등록하는 간단한 서버 요청/응답 코드를 예를 들어 보겠습니다. 이 요청/응답 프로토콜의 명세를 간략히 적어보면 다음과 같습니다. (편의를 위해 에러 처리는 생략했습니다.)

  • 요청을 받음
  • 요청 검증
  • 이메일 정규화(canonicalize)
  • DB의 유저 정보 업데이트
  • 검증 이메일 발송
  • 응답을 보냄

위 명세를 코드로 옮기면 다음과 같은 코드가 나와야 합니다.

function registerUser()
{
    var request = receiveRequest();
    validateRequest(request);
    canonicalizeEmail(request);
    updateDbFromRequest(request);
    sendEmail(request.Email);
    return sendResponse("Success");
}

바꿔 말해, 명세와 코드의 추상화 수준이 같아야 합니다. 이렇게 작성하면 코드를 읽는 사람 입장에서 validateRequestcanonicalizeEmail 같은 함수가 내부적으로 어떤 일을 수행하는지 살펴보지 않고도 전체 코드의 의도를 이해할 수 있습니다. 만약, 이런 함수를 별도의 함수로 분리하지 않고 registerUser 함수 안에 넣어서 작성했다면 코드를 의도를 읽기가 훨씬 힘들어졌을 겁니다.

재미 있는 사실은 대부분의 개발자가 코드 리뷰 시에는 본인이 실제로 코드를 어떻게 작성했는지와 상관 없이 명세와 유사한 추상화 수준으로 설명을 합니다. 코드 리뷰어가 설명을 듣고 있기 때문에 이해할 수 있게 설명하려면 계속 일정한 수준으로 설명을 하는 수밖에 없기 때문입니다. 코드를 다른 개발자에게 말로 설명해 보는 것은 적절한 추상화 수준의 가독성 높은 코드를 짜는 좋은 방법입니다. 즉, 말하듯이 코딩해야 합니다.