Cognitive Complexity, Because Testability != Understandability

>  이 글은 Cognitive Complexity, Because Testability != Understandability 를 번역한 글입니다.


토마스 J. 맥케이브(Thomas J. McCabe)는 1976년 순환 복잡도Cyclomatic Complexity\라는 메트릭을 소개했습니다. 이 메트릭은 프로그래머들에게 "테스트 가능하고, 유지보수 가능한testable and maintainable 코드를 작성하는 가이드로 활용되었습니다. SonarSource의 멤버들은 순환 메트릭이 테스트 가능성testability을 확인하는 데는 매우 유용하지만, 유지보수성을 측정하는 데는 그렇지 않다고 생각했습니다. 그래서 우리는 새로운 인지 복잡도Cognitive Complexity라는 메트릭을 소개합니다. 이 메트릭은 현 시점 이후의 언어 분석 엔진들에 적용됩니다. 이 메트릭을 활용해 여러분은 프로그램의 제어 흐름을 상대적으로 얼마나 잘 이해할 수 있는지 알 수 있습니다.

순환 복잡도는 유지보수성을 측정하지 않습니다.

다음 메소드를 보면서 이야기를 시작하겠습니다:

int sumOfPrimes(int max) {              // +1
  int total = 0;
  OUT: for (int i = 1; i <= max; ++i) { // +1
    for (int j = 2; j < i; ++j) {       // +1
      if (i % j == 0) {                 // +1
        continue OUT;
      }
    }
    total += i;
  }
  return total;
}                  // Cyclomatic Complexity 4
String getWords(int number) {   // +1
    switch (number) {
      case 1:                   // +1
        return "one";
      case 2:                   // +1
        return "a couple";
      default:                  // +1
        return "lots";
    }
  }        // Cyclomatic Complexity 4

이 두 메소드의 순환 복잡도는 동일하지만, 유지보수성이 동일하지는 않습니다. 물론 이 비교가 완전히 공평하지 않을 수 있으며, 맥케이브 본인 역시 그의 논문에서 swtich 구문과 case 구문의 처리가 동일하지 않을 수 있음을 명시하고 있습니다:

이 제한(메소드 당 10)이 불합리하게 보이는 유일한 경우는, 많은 수의 독립 케이스들이 선택 함수(큰 case 구문)의 뒤를 따르는 경우입니다...

달리 말하자면 이러한 점이 바로 순환 복잡도의 맹점입니다. 순환 복잡도 지표는 분명 해당 메소드를 커버하기 위해 필요한 테스트 케이스의 숫자를 알려줄 수는 있지만, 유지 보수성 관점에서 볼때는 항상 옳지는 않습니다. 또한, 아무리 단순한 메소드인 경우에도 순환 복잡도 값은 1이 되며, 거대한 하나의 도메인 클래스의 순환 복잡도 값과 매우 작고 복잡한 클래스의 순환 복잡도 값이 동일할 수도 있습니다. 그어플리케이션 레벨에서 이루어진 여러가지 연구 결과 순환 복잡도는 코드 라인수와 연관되어 있기 때문에, 실제로 새로운 개념을 나타내는 것이 아님이 알려져 있습니다.

그래서 인지 복잡도를 사용합니다!

그래서 우리는 인지 복잡도를 도입했습니다. 인지 복잡도는 메소드의 제어 흐름이 얼마나 이해하기 어려운지, 다시 말해 유지 보수가 얼마나 어려운지를 나타냅니다.

이후 구체적인 예를 들겠습니다만, 우선 인지 복잡도를 도입한 동기에 대해 말씀드리고자 합니다. 가장 큰 이유는 당연히 직관적으로 "공정한" 유지 보수성을 표시하는 것입니다. 그 과정에서 우리가 지표로 무엇인가를 나타낸다면, 사용자가 그 지표를 개선해나가야 한다는 점도 분명하게 알려야 합니다. 때문에 우리는 인지 복잡도를 매우 정교하게 설계해야 했고, 이해를 위해 추가적인 노력을 요구한 코드 구조를 발견하면 복잡도 값을 증가시키고, 읽기 쉽게 짜여진 코드 구조의 경우 복잡도 값을 증가시키지 않으면서 코딩 프랙티스를 다듬어야 했습니다.

기본 기준

아래와 같이 세 가지의 기본 원칙을 수립했습니다:

  • 코드의 순차적인 흐름을 끊는 요소break가 존재하는 경우, 인지 복잡도 값을 증가시킵니다.
  • 코드의 흐름을 끊는 요소가 중첩nested된 경우, 인지 복잡도 값을 증가시킵니다.
  • 복수의 코드 라인을 하나의 코드라인으로 작성했으나, 이해할 수 있을 정도로 합쳐진 코드 구조shorthand structure의 경우, 인지 복잡도 값을 증가시키지 않습니다.

코드 예제

위의 세 가지 기준에 따라 첫 번째 두 메소드를 다시 살펴보겠습니다:

                              // Cyclomatic Complexity    Cognitive Complexity
String getWords(int number) { //          +1
  switch (number) {           //                                  +1
    case 1:                   //          +1
      return "one";
    case 2:                   //          +1
      return "a couple";
    default:                  //          +1
      return "lots";
  }
}                             //          =4                      =1

앞서 이야기했듯, 순환 복잡도의 가장 큰 맹점은 switch 구문의 처리에 있습니다. 인지 복잡도는 순환 복잡도와는 대조적으로 switch 구조를 만난 경우에만 복잡도 값을 증가시키며, case 구문을 만난 경우에는 복잡도 값을 증가시키지 않습니다. 간단히 이야기하자면, switch 구문은 이해하기 매우 쉽기 때문입니다. 즉, 인지 복잡도는 논리 흐름을 얼마나 이해하기 어려운지를 표시합니다.

마찬가지로, 인지 복잡도는 다른 제어 흐름 구조(for, while, do while), 삼항 연산, if/#if/#ifdef/..., else if/elsif/elif/...else 등에 대해서도 case 구분과 같은 방법으로 측정됩니다. 추가적으로 특정 위치로 점프하는 경우(goto, break 및 continue)와 제어 흐름이 중첩되는 경우에는 복잡도를 증가시킵니다:

                                // Cyclomatic Complexity    Cognitive Complexity
int sumOfPrimes(int max) {              // +1
  int total = 0;
  OUT: for (int i = 1; i <= max; ++i) { // +1                       +1
    for (int j = 2; j < i; ++j) {       // +1                       +2 (nesting=1)
      if (i % j == 0) {                 // +1                       +3 (nesting=2)
        continue OUT;                   //                          +1
      }
    }
    total += i;
  }
  return total;
}                               //         =4                       =7

위에서 보듯, 인지 복잡도 값을 기준으로 sumOfPrimes() 메소드가 getWords() 메소드보다 이해하기 어렵다는 것을 알 수 있습니다--제어 흐름의 중첩과 continue 구문에 의한 제어 흐름 이동이 존재하기 때문입니다. 두 메소드의 순환 복잡도 값은 동일한 반면, 인지 복잡도 값은 두 메소드의 이해 가능성에 관한 큰 차이를 보여줍니다.

다음 코드를 보면, 인지 복잡도는 메소드 자체에 대해서는 증가시키지 않음을 알 수 었습니다. 즉, 간단한 도메인 클래스의 인지 복잡도 값은 0이 됩니다:

                              // Cyclomatic Complexity       Cognitive Complexity
public class Fruit {

  private String name;

  public Fruit(String name) { //        +1                          +0
    this.name = name;
  }

  public void setName(String name) { // +1                          +0
    this.name = name;
  }

  public String getName() {   //        +1                          +0
    return this.name;
  }
}                             //        =3                          =0

이를 통해 클래스 레벨 메트릭의 의미가 보다 분명해 집니다. 클래스 목록과 해당 클래스의 인지 복잡도 값을 참조해, 인지 복잡도 값이 높은 경우, 해당 클래스에 메소드가 많이 존재한다는 의미가 아니라 클래스의 로직이 많다는 것을 알 수 있습니다.

인지 복잡도를 사용해 보십시오

이제 인지 복잡도를 활용하기 위해 어떤 준비가 필요한지 대부분 아셨을 것입니다. 불리언 연사자 카운트 방법에는 다소 차이가 있으므로, 자세한 내용은 링크의 화이트 페이퍼를 읽어보시기 바랍니다. 인지 복잡도를 도입하고자 하는 경우, 그 도입시기에 관해 궁금하실 수 있을 것 같습니다.

우리는 우선 순환 복잡도와 마찬가지로 메소드 레벨에서 인지 복잡도 규칙을 각 언어에 적용할 것입니다. 주요 언어(Java, JavaScript, C#, C/C++/Objective-C)에 적용을 우선 시작합니다. 동시에 현재 메소드 단위의 "Cyclomatic Complexity" 규칙을 순수한 순환 복잡도 메트릭을 수정할 것입니다(현재 해당 규칙은 Cyclomatic 및 Essential 복잡도의 조합으로 구현되어 있습니다).

결과적으로 클래스 및 파일 레벨의 인지 복잡도 규칙과 메트릭을 추가할 예정입니다. 이제 한 걸음을 내딛었을 뿐입니다.


-----


인지 복자도는 SonarQube v6.5 및 최신 언어 플러그인을 기준으로 아래와 같이 적용되어 있습니다 (smile) 기존의 Complexity 메트릭과 함께 Cognitive Complexity 메트릭도 적용되어 있으므로, 활용해 보시면 좋을 것 같습니다 ^^

© 2017-2018 Moses Kim.

별도의 언급이 없는 한, 이 스페이스의 컨텐츠는 크리에이티브 커먼즈 저작자표시-비영리-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.
SONARQUBE는 SonarSource SA의 트레이드 마크입니다. 모든 트레이트 마크 및 저작권은 각 소유자의 소유물입니다.

::: SonarQube 관련 문의 : 이메일 :::