리팩토링

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

변화 예측

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

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

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

클래스 이름 명명법

이해하기 쉬운 코드를 작성하려면 이름을 잘 짓는 것이 무척 중요합니다. 게임 코드를 리뷰하면서 발견한 잘못된 클래스 이름의 사례는 다음과 같습니다.

  • 무의미한 접미사

생각보다 많은 개발자들이 습관적으로 Info, Data, Manager 등의 접미사를 붙여 클래스 이름을 만드는 습관이 있다는 사실을 발견했습니다. 예를 들어, 게임 캐릭터를 나타내는 클래스 이름은 CharacterInfo, 전투에 관련된 사실상 거의 모든 일을 하는 클래스의 이름은 BattleManager로 붙입니다.

클래스 이름은 읽는 사람 입장에서는 일종의 단서이기 때문에 반드시 의미가 있어야 합니다. Character와 CharacterInfo가 읽는 사람 입장에서 아무런 차이가 없다면 Character라는 간결한 클래스 이름을 사용하는 것이 낫습니다.

  • 두루뭉술한 이름

클래스 이름 자체가 너무 두루뭉술하게 무엇을 하는 클래스인지 알 수는 경우도 있습니다. 대표적인 예가 GameData 같은 이름입니다. 대게 게임 내에서 자주 사용하는 데이터나 메소드를 한 곳에 모아둔 것인데, 용도에 따라 클래스를 구분하지 않고 전부 한 곳에 두고 사용하기 때문에 발생하는 일입니다.

클래스가 한 가지 일만 하는 게 아니다 보니 딱히 이름을 지을 방법이 없어서 두루뭉술한 이름을 붙이게 됩니다. 이 경우는 클래스 이름을 잘못 지은 게 아니라 클래스 설계를 잘못한 경우입니다. 클래스 하나가 하는 일이 여러 개이기 때문에 마땅한 이름이 없는 것입니다.

이 경우 클래스에 명료한 이름을 붙일 수 있는 수준까지 해당 클래스를 리팩토링해야 합니다. 여러 개의 클래스로 나누고 클래스 하나 하나가 하는 일이 명료하다면, 각각의 클래스의 이름을 붙이는 일은 어렵지 않습니다.