코드 복잡도를 측정하는 방법
코드 복잡도란?
최신 소프트웨어 애플리케이션의 복잡성이 증가하면서 코드를 안정적이면서 지속적인 유지 보수 관리가 가능하도록 만드는 것이 점차 어려워지고 있습니다. 또한 애플리케이션은 기능 변경, 추가 혹은 프로젝트 목표 변경 등에 따라 지속해 변화하기에 이를 빠르고 안정적이게 대응하기 위해선 이 문제를 해결해야 합니다. 이를 해결하기 위한 여러 가지 개발 방법론이나 원칙들이 존재합니다. 이번에는 중요한 기준점이 될 수 있는 코드 복잡도에 대해서 이야기 해보겠습니다.
코드 복잡도는 소프트웨어 코드가 얼마나 복잡한지, 그리고 개발자가 얼마나 이해하기 어려운지를 나타내는 척도입니다.
코드 복잡도를 낮춰야 하는 이유
그렇다면 왜 코드 복잡도를 낮춰야 할까요?
코드 복잡도는 코드의 유지보수, 디버깅 등에 영향을 미치며, 더욱 복잡한 코드는 오류가 발생하기 쉽습니다. 또한 클린 코드 등의 개발 서적의 저자 로버트 마틴이 지적하기를 코드를 읽는 시간 대비 코드를 짜는 시간 비율이 약 10 : 1 을 넘는다고 합니다.
반대로 말하면 낮은 복잡도의 코드는 유지보수, 디버깅하기 수월하고, 오류가 발생할 가능성이 작으며, 코드를 읽는 시간이 줄어들었기에 동일한 시간 대비 작성할 수 있는 코드가 늘어 생산성이 향상합니다.
코드 복잡도를 측정하는 방법을 알게 된다면 이를 기준으로 코드 복잡도를 낮추어 위의 장점들을 얻어낼 수 있습니다.
코드 복잡도의 구성요소
코드 복잡도는 일반적으로 아래와 같은 요소들로 이루어집니다.
- 코드의 길이
- 중첩 레벨
- 제어 흐름 (control flow)
- 코드베이스 아키텍처
- 중복 코드
이 중 중첩 레벨과 제어 흐름에 깊게 관련된 코드 복잡도 측정법에 대해 아래서 다룰 것입니다. 이유는 비교적 측정하기 간편하고 측정된 수치와 코드 복잡도의 상관관계가 다양한 변인을 고려하더라도 큰 관련성이 있다고 생각하기 때문입니다.
cyclomatic complexity (순환 복잡도)
cyclomatic complexity (이하 순환 복잡도 지표)는 소프트웨어의 한 부분(유닛)의 코드의 논리적인 복잡도를 정량적으로 측정하는 방법으로, 1979년 McCabe가 고안한 소프트웨어 지표입니다.
순환 복잡도는 "소스 코드 함수의 의사 결정 논리 양"을 측정하는 것으로 정의됩니다.
복잡도가 낮을수록 프로그램이 구조적으로 안정되었다는 것을 의미하며, 높을수록 프로그램이 비구조적이며 불안정하다는 것을 의미합니다.
측정된 수치와 버그 발생률의 상관관계
McCabe가 "Software Quality Metrics to Indentify Risk"에 작성한 내용을 보면 순환 복잡도 값에 따라 아래와 같이 버그 발생률이 증가한다고 합니다
순환 복잡도 (Cyclomatic Complexity) | 위험도 (Risk Evaluation) |
---|---|
1 ~ 10 | 간단한 흐름, 낮은 위험도 |
11 ~ 20 | 조금 더 복잡함, 중간 위험도 |
21 ~ 50 | 복잡함, 높은 위험도 |
51 ~ | 불안정, 매우 높은 위험도 |
McCabe의 자료에서 권고하는 바는 10 이하입니다. 하지만 순환 복잡도의 정확한 기준점은 여전히 논란의 여지가 있습니다. 각 조직과 각 코드베이스에 일관되게 통용되는 값은 없기 때문입니다. Microsoft의 순환 복잡도 관련 문서를 보면 15까지의 제한도 성공적으로 사용되었음이 명시되어 있습니다. 위 자료를 참고삼아 각 프로젝트에 맞는 복잡도 제한을 선택하는 방향이 올바른 접근일 것입니다.
측정 방법
정확한 측정 방법은 프로그램의 제어 흐름 그래프(control flow graph)를 이용합니다. 하지만 누구나 개발 도중에도 할 수 있는 아주 간단한 측정 방법 또한 있습니다. 아래의 두 가지 계산식이 순환 복잡도를 측정하는 데 사용됩니다.
// 제어 흐름 그래프(control flow graph)를 사용하는 방식
V = E(Edge) - N(Node) + 2
// 제어 흐름 선언문을 카운팅하는 방식
V = C(제어 흐름 선언문) + 1
여기서 제어 흐름 선언문이란?
위의 이미지와 같이 코드의 흐름을 제어하는 선언문들을 제어 흐름 선언문이라고 합니다. 위의 측정 계산식들 중 아래가 코드 상에서 제어 흐름 선언문을 만날 때마다 1점씩 올리면 되는 아주 간단한 방식입니다.
예시
하나의 예시를 두고 두 가지의 측정 방법을 사용해 보겠습니다.
let x = 1;
while (x < 10) {
if (x % 2 === 0) {
console.log('x는 지금 짝수입니다!')
} else {
console.log('x는 지금 홀수입니다!')
}
x++;
}
1. 제어 흐름 선언문 카운팅
let x = 1;
while (x < 10) { // +1
if (x % 2 === 0) { // +1
console.log('x는 지금 짝수입니다!')
} else {
console.log('x는 지금 홀수입니다!')
}
x++;
}
>>> V = 2 (제어 흐름 선언문 개수) + 1 // V === 3
여기서 else를 카운팅하지 않는 게 이상해 보일 수도 있지만, 아래의 제어 흐름 그래프의 엣지와 노드를 사용해 카운팅하는 방식을 보면 쉽게 이해가 될 것입니다.
2. 제어 흐름 그래프를 이용하는 방식
위 코드는 제어 흐름 그래프로는 아래와 같습니다.
여기서 노드는 각 공을, 엣지는 각 화살표를 의미합니다. 중간에 2번 노드에서 3, 4번 노드로 분기되는 부분이 while 문 안의 if-else 입니다. else의 내용이 없다면 현재의 4번 노드가 없이 2번 노드에서 5번 노드로 엣지가 생길 텐데, 결과적으로 노드와 엣지의 수가 동일하게 1씩 줄어 동일한 값이 됨을 알 수 있습니다.
// else문이 있을 경우
V = 8(Edge) - 7(Node) + 2 // 3
//else문이 없는 경우
V = 7(Edge) - 6(Node) + 2 // 3
장단점
우선 순환 복잡도 측정법의 가장 큰 장점은 명확한 정량법이라는 것입니다. 결괏값를 이해하기도 쉽기에 긴 시간 동안 코드 복잡도를 측정하는 방법 중 하나로 사용되었습니다.
하지만 단점도 있습니다. 코드 복잡도란 단순히 제어 흐름의 가짓수로 결정되지 않는다는 점을 반영하지 못한다는 것입니다. 순환 복잡도의 값은 코드 복잡도와 분명한 상관관계가 있지만, 20이하의 낮은 수치에서는 강한 상관관계를 보이지 않습니다. 이의 대표적인 예시는 switch case 문입니다. 자세한 설명은 순환 복잡도를 보완하기 위해 나온 인지 복잡도(cognitive complexity)에서 함께 설명할 것 입니다.
cognitive complexity (인지 복잡도)
인지 복잡도는 G. Ann Campbell, SonarSource SA가 제안한 코드의 이해도라는 기준으로 순환 복잡도가 놓친 요소들을 보완한 코드 복잡도 측정법입니다.
순환 복잡도는 코드 복잡도를 어떤 경우에는 과하게, 어떠한 경우에는 부족하게 반영합니다. 이는 코드 복잡도가 제어 흐름의 가짓수뿐 아니라, 코드가 이해하기 좋은지, 읽기 좋은지 등도 포함되기 때문입니다.
인지 복잡도는 순환 복잡도 측정법의 정량적인 장점을 챙기면서, 코드가 이해하기 좋은지 안 좋은지를 반영할 수 있도록 추가적인 계산 기준들이 들어갑니다.
인지 복잡도에서 점수를 증가시키는 계산 기준들은 그 자체로 코드 복잡도의 안티 패턴 집합이기에 인지 복잡도 계산을 실제로 수행하지 않더라도 개발자 개개인이 코드 복잡도를 줄이기 위한 기준으로 삼기 좋습니다.
대표적인 예시
// 복잡한 중첩문
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;
}
// 순환 복잡도 점수: 4
// 단순한 중첩문
String getWords(int number) { // +1
switch (number) {
case 1: // +1
return "one";
case 2: // +1
return "a couple";
case 3: // +1
return “a few”;
default:
return "lots";
}
}
// 순환 복잡도 점수: 4
두 케이스는 모두 값은 순환 복잡도 점수를 가지지만, 위의 사례가 더욱 이해하기 어려운 코드라는 것을 모두 공감할 수 있습니다. 순환 복잡도는 이와 같은 단점을 보유하고 있기에 인지 복잡도는 이를 해결하는 기준들을 제시합니다.
기본 기준 및 방법론
인지 복잡성 점수는 세 가지 기본 규칙에 따라 평가됩니다:
- 여러 문장을 하나로 축약하여 읽을 수 있는 구조는 무시합니다.
- 코드의 선형적 흐름이 끊어질 때마다 점수가 증가(하나씩 추가)합니다.
- 흐름을 끊는 구조가 중첩될 때 증가합니다.
또한 복잡성 점수는 네 가지 유형의 점수 증분(increment)으로 구성됩니다:
- A. 중첩 - 제어 흐름 구조가 서로 중첩된 경우 평가됩니다.
- B. 구조적 - 중첩 증분이 적용되고 중첩 횟수가 증가하는 제어 흐름 구조에 대해 평가됩니다.
- C. 기본 - 중첩 증분이 적용되지 않는 문에 대해 평가됩니다.
- D. 하이브리드 - 중첩 증분이 적용되지 않지만, 중첩 횟수를 증가시키는 제어 흐름 구조에 대해 평가됩니다.
각 증분은 1점씩 최종 점수(인지 복잡도)에 합쳐집니다.
측정 기준
1. 코드 축약은 코드 복잡도를 높인다고 판단하지 않습니다.
const obj = { a : 'value'}
// 축약 전
let result;
if (obj && obj.a) {
result = obj.a;
}
// 축약 후
let result = obj?.a;
축약 전 코드를 자바스크립트의 옵셔널 체이닝을 사용하여 한 줄로 줄이는 예시 코드입니다.
과한 축약은 코드 복잡도를 오히려 올릴 수도 있으니 주의할 필요는 있습니다.
2. 코드의 선형적인 흐름을 끊는 요소들은 증분으로 처리합니다.
- Loop structures: for, while, do while, ...
- Conditionals: ternary operators, if, ...
- hybrid increments for: else if, elif, else, …
위와 같이 코드의 선형적인 흐름을 막는 순환 복잡도에서 설명한 제어 흐름 선언문과 같은 요소들을 마주할 경우 인지 복잡도 점수를 증가시킵니다.
2.1. catch는 if 와 같이 판단합니다.
catch 문은 if와 마찬가지로 제어 흐름에서 일종의 분기를 나타냅니다. 따라서 각 catch 문은 인지 복잡성의 구조적 증가를 초래합니다. 하지만 try 와 finally는 무시합니다. 특별한 조건이 필요한 구문이 아니기 때문입니다.
2.2 switch는 case가 몇 개든 1점입니다.
Switch 문은 일견 연쇄적인 if-else if로 보입니다. 하지만 개발자의 시선으로 볼 때 단일 값을 각각의 case 문에서 비교할 뿐이기에 이해하기에 용이한 제어 흐름 선언문입니다. 따라서 swicth case 문은 1점만 증가시킵니다.
2.3 연속된 논리 연산자들
논리 연산자들은 각각 점수를 매기지 않고 연속된 경우 하나로 묶어서 점수로 판단합니다. 이에 대한 이유는 아래와 같습니다.
a && b
a && b && c && d
a || b
a || b || c || d
동일한 논리 연산자가 연속되는 경우, 개수가 늘더라도 코드를 이해하는데 크게 문제가 되지 않음을 느낄 수 있습니다. 따라서 a && b && c && d
와 같은 연속적인 논리 연산자는 하나로 보면서 증분시킵니다.
이에 대한 자세한 예시는 아래와 같습니다.
if (a // +1 for 'if'
&& b && c // +1
|| d || e // +1
&& f // +1
)
if (a // +1 for 'if'
&& // +1
!(b && c) // +1
)
2.4 재귀함수는 일종의 루프로 취급합니다.
2.5 goto나 break, continue, 등은 증가 return은 예외적으로 점수로 판단하지 않습니다.
코드의 선형적인 흐름에서 빠른 return 처리는 코드를 간결하게 만들어 가독성을 높이는 등의 효과가 있기에 이를 감안하여 점수로 치지 않습니다.
그 이외의 goto 나 break, continue 등은 코드의 선형적인 흐름에 점프를 발생시키기에 점수를 증가시킵니다.
3. 중첩 레벨은 인지 복잡도 증가에 가중치로 작용합니다.
동일한 레벨의 다섯 개의 연속된 if 문 등은 간단히 생각해 보아도 크게 코드 복잡도를 높이거나 하지 않습니다. 하지만 다섯 개의 if 문이 중첩되는 경우에는 이야기가 다릅니다. 따라서 인지 복잡도 측정법에서는 중첩 레벨이 올라간 위치에서 발생하는 2번 항목들은 중첩 레벨만큼 가중 처리합니다.
예시를 보면 쉽게 이해할 수 있습니다.
function myFunc() {
try { // try,finally문에 의해 생긴 중첩은 중첩으로 판단하지 않습니다.
if (condition1) { // +1
for (let i = 0; i < 10; i++) { // +2 (중첩 레벨 = 1)
while (condition2) { ... } // +3 (중첩 레벨 = 2)
}
}
} catch (e) { // + 1
if (condition3) { ...} // +2 (중첩 레벨 = 1)
}
}
>>> 인지 복잡도 점수 9
메소드 등의 케이스는 아래와 같이 처리합니다.
function myFunc() {
const method = () => { // +0 (하지만 중첩레벨은 증가시킵니다.)
if (condition) { ... } // +2 (중첩 레벨 = 1)
}
}
인지 복잡도 측정법의 시사점
인지 복잡도의 측정 기준들을 알아보았습니다. 인지 복잡도 측정법은 개발자가 코드를 이해하기 좋은지에 대해서 점수를 메기기 위해 직관적으로 '올바른' 점수 기준을 제시합니다.
처음에 나온 순환 복잡도의 단점을 나타낸 예시를 다시금 인지 복잡도 측정법을 사용해 점수를 메겨보겠습니다.
// 복잡한 중첩문
int sumOfPrimes(int max) { // +1
int total = 0;
OUT: for (int i = 1; i <= max; ++i) { // +1
for (int j = 2; j < i; ++j) { // +2
if (i % j == 0) { // +3
continue OUT; // +1
}
}
total += i;
}
return total;
}
// 인지 복잡도 점수: 7
// 단순한 중첩문
String getWords(int number) { // +1
switch (number) { // +1
case 1:
return "one";
case 2:
return "a couple";
case 3:
return “a few”;
default:
return "lots";
}
}
// 인지 복잡도 점수: 1
마무리
코드 복잡도의 증가는 개발 과정에서 필연적으로 따라오는 요소입니다. 심지어 어떤 경우에는 개발자 개인의 어떠한 실수나 잘못 없이도 프로젝트 진행에 따라 코드 복잡도는 증가할 수 있습니다. 개발자의 행복한 개발 생활과 조직의 목표 추구에 분명한 걸림돌이 되는 요소입니다. 이를 완전히 없앨 방법은 없지만 위에서 제시된 기준 등을 이용하여 코드베이스가 커지는 과정에서 지속적으로 코드 복잡도를 낮추려는 노력이 필요할 것입니다.