[태그:] Cyclomatic Complexity

  • “내 코드는 얼마나 스파게티일까?” 맥케이브 순환 복잡도로 측정하기

    “내 코드는 얼마나 스파게티일까?” 맥케이브 순환 복잡도로 측정하기

    우리는 종종 잘 짜인 코드를 ‘읽기 쉽고 이해하기 편하다’고 말하고, 반대로 나쁜 코드를 ‘스파게티 코드’라고 부릅니다. 스파게티 코드는 로직의 흐름이 복잡하게 얽히고설켜 있어 어디서 시작해서 어디로 가는지 파악하기 어렵고, 작은 수정 하나가 예상치 못한 버그를 낳는 유지보수의 악몽을 선사합니다. 그렇다면 이러한 코드의 ‘복잡함’을 개발자의 주관적인 느낌이 아닌, 객관적인 숫자로 측정할 방법은 없을까요?

    1976년, Thomas J. McCabe, Sr.는 바로 이 질문에 대한 해답으로 ‘순환 복잡도(Cyclomatic Complexity)’라는 개념을 제시했습니다. 맥케이브 순환 복잡도는 프로그램의 논리적인 복잡도를 측정하는 소프트웨어 메트릭(metric)으로, 간단히 말해 해당 코드에 존재하는 ‘독립적인 실행 경로의 수’를 정량적으로 나타냅니다. 순환 복잡도 값이 높을수록, 그 코드는 더 많은 분기(if, while, for 등)를 가지고 있어 로직이 복잡하며, 잠재적인 오류를 포함할 가능성이 높고, 테스트하기 더 어렵다는 것을 의미합니다.

    순환 복잡도는 단순히 ‘복잡하다’는 막연한 느낌을 ‘복잡도 = 15’와 같은 명확한 숫자로 보여줌으로써, 우리가 작성한 코드의 건강 상태를 진단하고 리팩토링(refactoring)이 필요한 위험 구간을 식별하는 강력한 ‘코드 품질 혈압계’ 역할을 합니다. 본 글에서는 이 맥케이브 순환 복잡도의 정확한 의미와 계산 방법, 그리고 이를 활용하여 더 건강하고 테스트하기 쉬운 코드를 작성하는 실질적인 전략에 대해 깊이 있게 탐구해 보겠습니다.


    순환 복잡도의 계산: 3가지 방법, 하나의 답

    순환 복잡도는 프로그램의 제어 흐름 그래프(Control Flow Graph, CFG)를 기반으로 계산됩니다. 제어 흐름 그래프는 코드의 실행 흐름을 노드(Node, 코드 블록)와 엣지(Edge, 제어 흐름)로 시각화한 다이어그램입니다. 순환 복잡도를 계산하는 방법은 크게 세 가지가 있으며, 어떤 방법을 사용하더라도 동일한 결과가 나옵니다.

    방법 1: 그래프 공식 활용하기

    가장 기본적인 계산 공식입니다. 제어 흐름 그래프의 엣지(Edge, 화살표) 수와 노드(Node, 원) 수를 이용합니다.

    • V(G) = E – N + 2P
      • V(G): 그래프 G의 순환 복잡도
      • E: 그래프의 엣지(Edge) 수
      • N: 그래프의 노드(Node) 수
      • P: 연결된 컴포넌트(Connected Component)의 수. 보통 하나의 함수나 메소드를 분석하므로 P는 1이 됩니다. 따라서 공식은 E – N + 2로 단순화됩니다.

    하나의 시작 노드와 하나의 종료 노드를 가진 그래프에서는 이 공식이 항상 성립합니다.

    방법 2: 분기점(Decision Point) 개수 활용하기

    코드를 직접 보면서 가장 직관적이고 빠르게 복잡도를 계산할 수 있는 방법입니다. 코드 내에 있는 조건 분기문의 개수를 세는 것입니다.

    • V(G) = (분기문의 개수) + 1

    여기서 분기문에 해당하는 것은 다음과 같습니다.

    • if, else if, else:if와 각 else if는 분기점에 해당합니다. (단, else는 별도로 세지 않습니다. if-else 구조는 하나의 분기입니다.)
    • switch-case:switch문 자체는 분기점이 아니고, 그 안의 각 case 문이 분기점에 해당합니다. (단, default는 세지 않습니다.)
    • for, while, do-while: 반복문 자체를 하나의 분기점으로 봅니다.
    • AND(&&) 와 OR(||):if (condition1 && condition2)와 같은 복합 조건문에서 각 논리 연산자도 하나의 추가적인 분기점으로 계산합니다.
    • 삼항 연산자 (?):condition ? true_case : false_case 역시 하나의 분기점입니다.

    방법 3: 영역(Region) 개수 활용하기

    제어 흐름 그래프를 평면에 그렸을 때, 엣지로 둘러싸인 닫힌 공간(영역, Region)의 개수를 세는 방법입니다. 그래프 바깥의 무한한 공간도 하나의 영역으로 포함합니다.

    • V(G) = (그래프 내 영역의 개수)

    이 방법은 그래프를 시각적으로 그려야 하므로 코드가 복잡해지면 사용하기 어렵지만, 순환 복잡도의 개념을 직관적으로 이해하는 데 도움이 됩니다.


    실제 코드로 계산해보기

    다음과 같은 간단한 자바 코드가 있다고 가정해 봅시다. 이 코드의 순환 복잡도를 세 가지 방법으로 모두 계산해 보겠습니다.

    Java

    public void checkGrade(int score) {
    char grade; // 1
    if (score >= 90 && score <= 100) { // 2
    grade = 'A'; // 3
    } else if (score >= 80) { // 4
    grade = 'B'; // 5
    } else { // 6
    grade = 'F'; // 7
    }
    System.out.println("Grade: " + grade); // 8
    }

    제어 흐름 그래프 (Control Flow Graph) 그리기

    위 코드의 흐름을 그래프로 그리면 다음과 같습니다.

    • 노드(Node): 1, 2, 3, 4, 5, 6, 7, 8 (총 8개)
      • (6번 라인의 else는 별도 노드가 아니라 4번 노드에서 분기되는 경로입니다.)
      • 시작(Start)과 끝(End)을 나타내는 가상의 노드를 추가할 수도 있습니다. 여기서는 코드 블록 자체를 노드로 보겠습니다. 1번 노드가 시작, 8번 노드가 종료 지점으로 수렴합니다.
    • 엣지(Edge):
      • 1 → 2
      • 2 → 3 (true, true)
      • 2 → 4 (false)
      • 3 → 8
      • 4 → 5 (true)
      • 4 → 7 (false, else 블록)
      • 5 → 8
      • 7 → 8
      • score >= 90 && score <= 100 조건에서 첫 번째 조건만 false일 때 4로 가는 엣지가 하나 더 있습니다.
      • 따라서 총 엣지의 수는 복잡해집니다. (이래서 공식 1은 그래프가 복잡할 때 직관적이지 않습니다. 단순화된 그래프로 보면 엣지는 8개입니다. 하지만 정확한 분석을 위해서는 복합 조건문을 분리해야 합니다.)

    정확한 계산을 위해 그래프 공식을 적용하기 전에, 더 쉬운 2번과 3번 방법부터 사용해 보겠습니다.

    방법 2 (분기점 개수)로 계산하기:

    가장 간단하고 명확한 방법입니다.

    1. if (score >= 90 && score <= 100)if문 자체에서 분기점 1개.
    2. && 논리 연산자: 추가 분기점 1개.
    3. else if (score >= 80)else if문에서 분기점 1개.
    4. else: 별도로 세지 않습니다.

    따라서 총 분기점의 개수는 1 + 1 + 1 = 3개입니다.

    • V(G) = 3 + 1 = 4

    방법 1 (그래프 공식)과 방법 3 (영역 개수)로 검증하기:

    복합 조건문 &&를 분리하여 더 정확한 제어 흐름 그래프를 그려봅시다.

    • 노드 1: char grade;
    • 노드 2: if (score >= 90)
    • 노드 3: if (score <= 100) (노드 2가 true일 때만 진입)
    • 노드 4: grade = 'A';
    • 노드 5: else if (score >= 80) (노드 2 or 3이 false일 때 진입)
    • 노드 6: grade = 'B';
    • 노드 7: grade = 'F'; (노드 5가 false일 때 진입)
    • 노드 8: System.out.println(...)

    이러한 상세 그래프를 그리면, 노드와 엣지의 개수를 세는 것이 매우 복잡해집니다. 하지만 단순화된 그래프에서 영역의 개수를 세어봅시다.

    • 영역 1: 2→3→8→4→2 사이의 공간
    • 영역 2: 4→5→8→7→4 사이의 공간
    • 영역 3: 2→4로 바로 가는 경로와 전체를 둘러싼 공간
    • 영역 4: 전체 그래프의 바깥 공간

    그래프를 어떻게 그리느냐에 따라 영역을 세는 것이 주관적이 될 수 있지만, 올바르게 그렸다면 4개의 영역이 나옵니다.

    • V(G) = 4

    결론적으로, 세 가지 방법 모두 순환 복잡도는 4 라는 동일한 결과를 가리킵니다.


    순환 복잡도는 우리에게 무엇을 말해주는가?

    순환 복잡도 값이 4라는 것은 무엇을 의미할까요? 이는 이 checkGrade 함수의 모든 실행 경로를 ‘완벽하게’ 테스트하기 위해서는 최소 4개의 독립적인 테스트 케이스가 필요하다는 것을 의미합니다.

    • TC 1 (경로 1):score = 95 (if문의 && 조건 모두 true → ‘A’)
    • TC 2 (경로 2):score = 101 (if문의 첫 조건은 true, 두 번째 조건 false → ‘F’) *복합조건 고려시
    • TC 3 (경로 3):score = 85 (첫 if문 false, else if문 true → ‘B’)
    • TC 4 (경로 4):score = 50 (첫 if문 false, else if문 false → ‘F’)

    이처럼 순환 복잡도는 테스트의 ‘최소 커버리지 기준’을 제시합니다. V(G)가 10이라면, 분기 커버리지(Branch Coverage) 100%를 달성하기 위해 최소 10개의 테스트 케이스가 필요하다는 강력한 지표가 됩니다.

    복잡도에 따른 코드 품질 등급

    업계에서는 일반적으로 다음과 같은 기준으로 순환 복잡도를 평가합니다.

    • 1 ~ 10: A simple program, without much risk. (간단하고 위험도가 낮은 프로그램)
      • 코드가 명확하고 이해하기 쉬우며, 유지보수하기 좋은 상태입니다. 이 구간을 유지하는 것이 가장 이상적입니다.
    • 11 ~ 20: More complex, moderate risk. (다소 복잡하며, 중간 정도의 위험도)
      • 로직이 복잡해지기 시작하는 단계로, 리팩토링을 고려해봐야 합니다. 함수나 메소드를 더 작은 단위로 분리하는 것이 좋습니다.
    • 21 ~ 50: Complex, high-risk program. (복잡하고, 높은 위험도를 가진 프로그램)
      • 코드를 이해하고 수정하기가 매우 어렵습니다. 잠재적인 버그가 많을 가능성이 높으며, 테스트가 매우 힘들어집니다. 반드시 리팩토링이 필요합니다.
    • 50 이상: Untestable, very high-risk program. (테스트가 거의 불가능하며, 매우 높은 위험도)
      • ‘스파게티 코드’의 전형으로, 이 코드를 건드리는 것은 매우 위험합니다. 처음부터 새로 작성하는 것이 더 나을 수도 있습니다.

    SonarQube와 같은 정적 분석 도구들은 자동으로 코드의 순환 복잡도를 계산해주고, 설정된 임계값(예: 15)을 초과하는 메소드가 있으면 경고를 표시하여 개발자가 지속적으로 코드 품질을 관리하도록 돕습니다.


    마무리: 복잡도를 낮추는 것이 품질의 시작

    맥케이브 순환 복잡도는 코드의 품질을 논할 때 빼놓을 수 없는 핵심적인 지표입니다. 이것은 단순히 숫자를 넘어, 우리 코드의 ‘건강 상태’를 알려주는 객관적인 신호입니다.

    • 테스트 용이성: 순환 복잡도는 필요한 테스트 케이스의 수를 알려주어, 체계적인 테스트 전략을 수립하는 데 도움을 줍니다.
    • 유지보수성: 복잡도가 낮을수록 코드를 이해하고 수정하기 쉬워지므로, 유지보수 비용이 감소합니다.
    • 잠재적 결함 예측: 복잡도가 높은 코드는 사람이 실수할 가능성이 높은 코드이며, 이는 곧 잠재적인 버그로 이어집니다. 복잡도 관리는 곧 버그 예방입니다.

    만약 당신의 코드가 순환 복잡도 10을 초과하기 시작했다면, 잠시 멈추고 리팩토링을 고민해봐야 합니다. 거대한 if-else if-else 블록은 다형성(Polymorphism)을 활용한 전략 패턴(Strategy Pattern)으로 개선할 수 없는지, 중첩된 for문과 if문은 더 작은 메소드로 분리(Extract Method)할 수 없는지 등을 점검해야 합니다.

    복잡한 문제를 해결하기 위해 복잡한 코드를 짜는 것은 어쩔 수 없다고 변명하기 쉽습니다. 하지만 진정한 실력은 복잡한 문제를 ‘단순하게’ 풀어내는 능력에 있습니다. 순환 복잡도라는 혈압계를 항상 옆에 두고, 우리 코드의 건강을 꾸준히 관리하는 습관이야말로 우리를 더 나은 개발자로 이끌어 줄 것입니다.