본문 바로가기

카테고리 없음

VSCode 확장 프로그램 만들기 (2) - AST와 코드 복잡도 측정

코드 품질을 객관적으로 측정하려면 어떻게 해야 할까?
이번 글에서는 AST(Abstract Syntax Tree)와 순환 복잡도, 인지 복잡도 등 이번 프로젝트에서 주요하게 다른 코드 분석 내용을 다룰 것이다.

 

AST(Abstract Syntax Tree)란?

AST는 소스 코드의 구조를 트리 형태로 표현한 자료구조다. 코드의 문법적 구조를 추상화하여 컴파일러나 분석 도구가 이해할 수 있는 형태로 변환한다. 예를 들어,

const add = (a, b) => a + b;

 

이 코드는 AST로 다음과 같이 표현된다.

 

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "add" },
      "init": {
        "type": "ArrowFunctionExpression",
        "params": [
          { "type": "Identifier", "name": "a" },
          { "type": "Identifier", "name": "b" }
        ],
        "body": {
          "type": "BinaryExpression",
          "operator": "+",
          "left": { "type": "Identifier", "name": "a" },
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    }
  ]
}

 

Babel Parser를 사용한 AST 파싱을 통해 추상화된 내용을 이용해 분석 도구 등에 사용할 수 있다.

JavaScript/TypeScript 생태계에서 가장 널리 사용되는 파서는 Babel Parser다. src/analyzer.ts에서 다음과 같이 구현했다.

 

import { parse } from '@babel/parser';
import type { File } from '@babel/types';

export function parseCodeToAST(code: string): File {
  try {
    const ast = parse(code, {
      sourceType: 'module',
      plugins: ['jsx', 'typescript'],
    });
    return ast;
  } catch (error) {
    console.error('AST Error:', error);
    throw new Error('코드를 AST 객체로 파싱하는 데에 실패하였습니다.');
  }
}

 

plugins: ['jsx', 'typescript'] 설정으로 React와 TypeScript 문법을 모두 지원한다. sourceType: 'module'은 ES 모듈 형식으로 파싱하겠다는 의미다.


해당 메서드를 통해 앞으로 모든 코드를 일관된 방법으로 파싱할 수 있게 되고, 파싱된 내용의 노드, 값 등을 분석해 복잡도 계산 등에 이용한다.

 

 

순환 복잡도(Cyclomatic Complexity)

순환 복잡도는 코드의 논리적 경로 수를 측정하는 지표다.
Thomas McCabe가 1976년에 제안한 이론으로, 코드의 테스트 난이도와 유지보수성을 예측하는 데 사용된다.

 

계산 공식: CC(순환 복잡도) = N(분기점의 개수) + 1

 

분기점이 되는 요소:

  • if, else if
  • for, while, do-while
  • switch-case
  • 논리 연산자 (&&, ||)
  • 삼항 연산자 (? :)
  • catch 블록

 

즉, 복잡도를 높이는 요소인 분기점을 기준으로 몇 개의 분기가 존재하는지를 계산하면 된다.

 

src/complexity.ts에서 구현한 기본 순환 복잡도 계산 로직이다.

export function calculateComplexity(ast: File): number {
  let complexity = 1;

  traverse(ast, {
    IfStatement() {
      complexity++;
    },
    ConditionalExpression() {
      complexity++;
    },
    LogicalExpression(path: NodePath<LogicalExpression>) {
      const operator = path.node.operator;
      if (operator === '&&' || operator === '||') {
        complexity++;
      }
    },
    SwitchCase(path: NodePath<SwitchCase>) {
      if (path.node.test !== null) {
        complexity++;
      }
    },
    ForStatement() {
      complexity++;
    },
    WhileStatement() {
      complexity++;
    },
    CatchClause() {
      complexity++;
    },
  });

  return complexity;
}

 

@babel/traverse를 사용하면 AST의 특정 노드 타입을 방문하면서 복잡도를 카운팅할 수 있다.


AST 상에서 if문은 type을 IfStatement 값으로 가지므로 위와 같이 각 분기 방문시마다 검증을 통해 복잡도를 카운팅하는 방식이다.
AST를 통해 대체로 타입을 가려낼수 있으므로 각 조건에 대한 예외 등을 처리할 수도 있다. 지금처럼 정해진 패턴에서는 괜찮지만 구현자의 판단과 기준이 많이 들어갈수록 섬세한 예외처리가 필요하고 이때에 유용하게 사용될 수 있다.

 

 

인지 복잡도(Cognitive Complexity)

순환 복잡도는 객관적이지만, 실제 코드의 이해 난이도를 정확히 반영하지 못할 때가 있다. 단순히 분기가 많다는 것만으로는 복잡도의 정도를 판별하기 어렵기 때문이다. 또한 복잡하다라는 의미는 사람의 기준에서 적용되는 개념이기에, 사람이 코드를 읽고 이해할 때의 난이도를 측정하는 것이 더 올바르다고 생각한다.


이를 개선하기 위해 순환 복잡도에서 인지 복잡도로 방식을 변경하였는데 , SonarSource가 제안한 인지 복잡도는 코드를 읽는 사람의 관점에서 복잡도를 측정하기 때문이다.

 

인지 복잡도의 핵심 원칙:

중첩 깊이에 가중치 부여: 중첩이 깊을수록 복잡도 증가
Early Return 패턴 인정: Guard Clause는 복잡도를 낮춤
선형적 구조 선호: 순차적인 조건문은 복잡도를 낮게 평가

 

src/complexity.ts에서 구현한 인지 복잡도 계산 로직이다.

export function calculateCognitiveComplexity(ast: File): {
  complexity: number;
  hotspots: ComplexityHotspot[];
} {
  let cognitiveComplexity = 0;
  let nestingLevel = 0;
  const hotspots: ComplexityHotspot[] = [];

  traverse(ast, {
    IfStatement: {
      enter(path: NodePath<IfStatement>) {
        // Early Return 패턴 감지
        const isEarlyReturn =
          path.node.consequent.type === 'BlockStatement' &&
          path.node.consequent.body.length === 1 &&
          path.node.consequent.body[0].type === 'ReturnStatement' &&
          !path.node.alternate;

        if (isEarlyReturn) {
          cognitiveComplexity += 1;  // Early Return은 복잡도 낮게 평가
        } else {
          const complexity = 1 + nestingLevel;  // 중첩 깊이만큼 가중치
          cognitiveComplexity += complexity;

          // 중첩이 깊은 코드를 핫스팟으로 기록
          if (nestingLevel >= 2) {
            hotspots.push({
              type: 'IfStatement',
              line: path.node.loc?.start.line || 0,
              nestingLevel,
            });
          }

          nestingLevel++;
        }
      },
      exit(path: NodePath<IfStatement>) {
        const isEarlyReturn = /* ... */;
        if (!isEarlyReturn) {
          nestingLevel--;
        }
      },
    },
  });

  return { complexity: cognitiveComplexity, hotspots };
}

 

이 로직의 핵심은 enter와 exit 훅을 사용해 중첩 깊이를 추적하는 것이다. if 문에 진입할 때 nestingLevel을 증가시키고, 빠져나올 때 감소시킨다.


인지 복잡도는 각 분기마다 이해를 방해하는 정도를 반영하기 위해 가중치를 부여해 계산하는 것이므로, 기본적으로 인지 복잡도에서의 가중치는 depth를 기준으로 한다.


이를 이해하기 위해 예제 코드를 살펴보자.

function foo(a, b, c) {
   if(a > b) {
      if(b > c) {
         ...
      }
   }
   if(a < b) {
      ...
   }
}

 

해당 파일의 인지 복잡도는 4이다.


복잡도 증가분은 1 + N(중첩 레벨, 가중치) 인데, if(a > b) 에서 1+0, if(b > c) 에서 1+1 이다.

 

인지 복잡도의 분기별 처리 기준:

  • if문> 현재 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N
  • 삼항연산자> 현재 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N
  • 논리연산자> 중첩이 아니기 때문에 항상 기본 복잡도만 증가, (복잡도) = 1
  • switch문> default 케이스는 제외하고 각 case마다 복잡도 증가(case마다 중첩), (복잡도) = 1 + N
  • 반목문> 반복문은 중첩을 만들기 때문에 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N
  • catch문> 예외처리 역시 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N

 

함수 길이 분석

 

긴 함수는 가독성과 유지보수성을 해치는 주요 원인이다.
다만, React 컴포넌트처럼 JSX를 반환하는 함수는 예외로 처리해야 한다. 현재로서는 JSX도 예외해두었지만, JSX 역시 길면 가독성에 영향을 주기 때문에 이를 어떻게 처리해야할지 고민중이다.

export function calculateLengthPenalty(ast: File): {
  penalty: number;
  longFunctions: LongFunction[];
} {
  const MAX_RECOMMENDED_LENGTH = 30;
  const longFunctions: LongFunction[] = [];

  traverse(ast, {
    'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
      const startLine = path.node.loc?.start.line || 0;
      const endLine = path.node.loc?.end.line || 0;
      const functionLength = endLine - startLine + 1;

      // JSX를 포함한 컴포넌트는 제외
      let hasJSX = false;
      path.traverse({
        JSXElement() { hasJSX = true; },
        JSXFragment() { hasJSX = true; },
      });

      if (hasJSX) {
        return;
      }

      if (functionLength > MAX_RECOMMENDED_LENGTH) {
        const functionName =
          path.node.type === 'FunctionDeclaration' && path.node.id
            ? path.node.id.name
            : '익명 함수';

        longFunctions.push({
          name: functionName,
          startLine,
          endLine,
          length: functionLength,
        });
      }
    },
  });

  const maxPenalty = longFunctions.length > 0
    ? Math.max(...longFunctions.map(f => Math.floor((f.length - MAX_RECOMMENDED_LENGTH) / 10)))
    : 0;

  return { penalty: maxPenalty, longFunctions };
}

 

path.traverse()를 사용하면 특정 노드 내부만 순회할 수 있다. 이를 통해 함수가 JSX를 포함하는지 확인한다.
좀 전에 봤던 방식과 일치하고, 이를 이용해 각 함수의 길이를 확인하고 너무 길어지지 않도록 조절할 수 있게 돕는다.

 

클린 코드 규칙 검사

AST를 활용하면 코딩 컨벤션 위반도 감지할 수 있다.
특정 값이나 기호 등을 타입을 통해 검사하고 원하는 규칙으로 사용하도록 설정할 수 있다. 앞서 언급한 것처럼 이는 주관이 개입하기 때문에 예외에 대한 처리가 확실하게 되어야한다.
또한, 클린 코드에 대한 기준은 개인별로 다를 수 있기에 추후 개별적으로 설정할 수 있도록 하는 옵션도 추가할 예정이다.

 

src/cleanCodeRules.ts에서 다음 규칙들을 검사한다.

 

느슨한 동등 연산자 (==, !=)

traverse(ast, {
  BinaryExpression(path: NodePath<BinaryExpression>) {
    if (path.node.operator === '==' || path.node.operator === '!=') {
      violations.push({
        rule: 'no-loose-equality',
        message: `'${path.node.operator}' 대신 '${path.node.operator === '==' ? '===' : '!=='}'를 사용하세요`,
        line: path.node.loc?.start.line,
      });
    }
  },
});

 

 

매직 넘버

const ALLOWED_NUMBERS = new Set([0, 1, -1]);

traverse(ast, {
  NumericLiteral(path: NodePath<NumericLiteral>) {
    const value = path.node.value;

    if (ALLOWED_NUMBERS.has(value)) {
      return;
    }

    const parent = path.parent;
    if (
      parent.type === 'MemberExpression' ||
      parent.type === 'ObjectProperty' ||
      parent.type === 'ArrayExpression'
    ) {
      return;
    }

    violations.push({
      rule: 'no-magic-number',
      message: `매직 넘버 '${value}' 대신 상수를 사용하세요`,
      line: path.node.loc?.start.line,
    });
  },
});

<img src='/img' width={20} height={20} /> 과 같은 경우에 체크되는 숫자 값도 있으니 이를 제외하기 위해
parent 노드를 확인하여 배열이나 객체 리터럴의 숫자는 제외하였다.

 

과다한 파라미터

const MAX_PARAMETERS = 5;

traverse(ast, {
  'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
    const paramCount = path.node.params.length;

    if (paramCount > MAX_PARAMETERS) {
      const functionName =
        path.node.type === 'FunctionDeclaration' && path.node.id
          ? path.node.id.name
          : '익명 함수';

      violations.push({
        rule: 'max-parameters',
        message: `함수 '${functionName}'의 파라미터가 ${paramCount}개입니다 (최대 ${MAX_PARAMETERS}개)`,
        line: path.node.loc?.start.line,
      });
    }
  }
});

 

종합 복잡도 점수(CCS_Refined)

 

여러 지표를 하나의 점수로 통합하기 위해 CCS(Comprehensive Complexity Score) 공식을 설계했다.

export function calculateRefinedComplexityScore(ast: File): {
  ccs: number;
  cognitiveComplexity: number;
  lengthPenalty: number;
  maxNestingDepth: number;
  longFunctions: LongFunction[];
  complexityHotspots: ComplexityHotspot[];
} {
  const cognitiveResult = calculateCognitiveComplexity(ast);
  const lengthPenaltyResult = calculateLengthPenalty(ast);
  const maxNestingDepth = calculateMaxNestingDepth(ast);

  const ccs = 1.0 * cognitiveResult.complexity + 0.5 * lengthPenaltyResult.penalty;

  return {
    ccs,
    cognitiveComplexity: cognitiveResult.complexity,
    lengthPenalty: lengthPenaltyResult.penalty,
    maxNestingDepth,
    longFunctions: lengthPenaltyResult.longFunctions,
    complexityHotspots: cognitiveResult.hotspots,
  };
}

 

CCS 계산식: 1.0 × 인지 복잡도 + 0.5 × 길이 페널티 + 5.0 × 위반 건수 각 가중치는 실험적으로 조정한 값이다. 인지 복잡도가 가장 중요하고, 클린 코드 위반은 큰 페널티를 준다.
이 역시도 필자의 주관적이고 실험적인 가중치이기에 차차 수정되어야할 것으로 보인다.

 

랭킹 시스템

최종 점수를 S/A/B/C/D/F 등급으로 변환한다.
현 코드의 상태를 보다 쉽게 파악하고 재미 요소도 가미하기 위해 추가하였다.
현재는 각 파일에 대해 액티브될 경우에만 표현되지만, 전체 파일에 대한 평가 혹은 깃허브를 기준으로 하는 커밋내역에 대한 평가도 구상중에 있다. (다만 확장 프로그램의 형태로는 구현이 불가해 다른 방향을 모색해야한다.)

 

src/ranking.ts에서 구현했다.

export function assignRefinedRank(ccs: number, violationCount: number): Rank {
  const violationPenalty = violationCount * 5;
  const finalScore = ccs + violationPenalty;

  if (finalScore <= 5 && violationCount === 0) {
    return 'S';
  } else if (finalScore <= 10 && violationCount <= 1) {
    return 'A';
  } else if (finalScore <= 20 && violationCount <= 3) {
    return 'B';
  } else if (finalScore <= 30 && violationCount <= 5) {
    return 'C';
  } else if (finalScore <= 40 || violationCount <= 8) {
    return 'D';
  } else {
    return 'F';
  }
}

 

S 등급을 받으려면 복잡도가 낮고 위반 사항이 전혀 없어야 한다.

전반적으로 조건을 구성하며 완벽한 코드라는 것에 대한 기준이 모호하기에 사실상 A등급 정도만 되어도 충분히 깔끔하고 가독성 좋은 코드라고 보인다. 여기까지 설계를 마치며 드는 생각은 평가에 대한 엣지 케이스가 종종 보인다는 점이다. 단순히 예를 들어 파일의 길이가 짧으면 좋은 평가를 받기 쉬워진다. 그렇다고 파일의 길이가 길어야 좋은 것은 아니기에 "적절함"이라는 기준에 맞추기가 쉽지 않다.