Go 팀이 x/tools 저장소에서 제공하는 analysis 패키지는 '모듈형 정적 분석'과 이를 실행하는 드라이버 프로그램 사이의 인터페이스를 규정한다. 정적 분석이란 Go 코드 패키지를 검사해 진단(diagnostic), 즉 대체로 코드의 실수를 보고하는 함수를 말한다. 여기에 더해 리팩터링 제안이나 다른 분석에 쓸 정보를 부산물로 만들어내기도 한다. 실수를 보고하는 분석은 흔히 '체커(checker)'라 불리는데, 대표적인 예가 fmt.Printf 포맷 문자열의 오류를 잡아내는 printf 체커다. 이 프레임워크의 핵심 취지는, 서로 다른 곳에서 만들어진 체커라도 공통 인터페이스만 구현하면 명령줄 도구인 vet은 물론 편집기·IDE, go build나 Bazel·Buck 같은 빌드 시스템, 코드 리뷰 도구, SourceGraph 같은 코드베이스 인덱서까지 폭넓은 실행 환경에서 손쉽게 골라 끼워 넣고 재사용할 수 있게 하는 데 있다.
왜 '모듈형'인가
여기서 '모듈형'이란 한 번에 패키지 하나만 검사하되, 하위 계층 패키지에서 얻은 정보를 저장했다가 상위 계층 패키지를 검사할 때 활용할 수 있다는 뜻이다. 컴파일러의 분리 컴파일(separate compilation)과 같은 발상이다. 대규모 프로그램을 빌드할 때 단위별로 따로 컴파일하고 의존성이 바뀐 것만 다시 컴파일하며 독립 모듈을 병렬로 처리하듯, 정적 분석에도 같은 기법을 적용해 확장성과 효율을 얻는다. printf 체커가 바로 이 방식으로 동작한다. log.Fatalf 같은 함수가 내부적으로 fmt.Printf에 위임한다는 사실을 발견하면 이를 기록해 두고, 다른 패키지에서 그 함수를 호출하는 지점까지 같은 규칙으로 검사한다. 컴파일러의 타입 검사기가 모듈형 정적 분석의 원형이라면, 우리가 Go에 적용하고 싶은 여러 검사도 결국 표준이 아닌 대안적 타입 시스템으로 이해할 수 있다는 것이 이 설계의 밑바탕이다.
Analyzer와 Pass, 그리고 확장의 경제학
API의 중심 타입은 Analyzer다. 분석 함수의 이름, 문서, 플래그, 다른 분석과의 관계, 그리고 실제 로직을 정적으로 기술한다. 개발자는 사실상 상수처럼 취급되는 Analyzer 타입 변수를 하나 선언해 분석을 정의하고, 드라이버는 필요한 Analyzer 목록을 임포트해 실행한다. 기존 드라이버에 새 검사를 추가하는 일은 목록에 항목 하나를 더하는 정도로 끝난다. Analyzer에는 여러 필드가 있다. Requires는 이 분석이 의존하는 다른 분석들을 지정해 실행 순서를 제약하고, ResultType은 다른 분석이 가져다 쓸 결과값의 타입을 정한다. RunDespiteErrors는 파싱·타입 오류가 있는 코드를 다룰 수 있는지를 나타내며, 그렇지 않으면 드라이버가 해당 분석을 건너뛴다.
실행 단위는 Pass 타입이 담당한다. 특정 Analyzer를 특정 패키지에 적용하는 한 번의 작업을 표현하며, Run 함수에 구문 트리(Files), 타입 정보(TypesInfo), 소스 위치(Fset), 패키지 정보(Pkg)를 넘겨준다. 흥미로운 대목은 ResultOf 맵을 통한 확장 구조다. inspect 분석기는 구문 트리를 더 효율적으로 순회하게 해 주는 값을, buildssa는 SSA 형태의 중간 표현을, ctrlflow는 함수별 제어 흐름 그래프를 결과로 내놓는다. 이들은 코어 API에 의존성을 더하지 않으면서 후속 분석기의 능력을 확장하기 때문에, 분석 도구는 자신이 필요로 하는 확장에 대해서만 비용을 치른다. 비-Go 파일도 다룰 수 있어, asmdecl이나 buildtags 분석기는 어셈블리 같은 원시 텍스트를 Pass.ReadFile로 읽어 해당 줄에 진단을 보고한다.
Fact와 진단, 그리고 실무적 함의
진단은 소스 위치에 결부된 메시지이며 대부분의 분석에서 최종 산출물이다. 눈여겨볼 점은 Diagnostic 구조체에 심각도(severity) 필드가 없다는 것이다. 어떤 분석과 진단이 더 중요한지에 대한 견해는 사용자마다 크게 갈리므로, 프레임워크는 심각도 판단을 각 Analyzer에 떠넘기지 않는다. 대신 드라이버가 생산자 Analyzer와 선택적 Category를 기준으로 사용자가 필터링과 우선순위를 직접 조정하도록 맡긴다. 이는 도구를 만드는 쪽이 정책 결정을 사용자에게 위임하도록 강제하는, 나름의 철학이 담긴 설계다.
그 자체로는 흥미롭지 않지만 진단이라는 최종 결과에 이르는 디딤돌이 되는 정보를 이 프레임워크는 Fact라 부른다. 'f는 printf 래퍼다' 같은 명제가 그 예다. Fact를 쓰는 분석기는 그 타입을 FactTypes로 선언해야 하고, 드라이버가 패키지 사이로, 때로는 주소 공간을 넘어 전파하기 때문에 Fact는 직렬화 가능해야 한다. API는 gob 인코딩을 요구하며, 콘텐츠 주소 기반 캐시를 쓰는 빌드 시스템에서 헛된 캐시 미스를 피하려면 인코딩이 결정적이어야 한다. 한 가지 실무적 함정도 있다. Bazel·Blaze 기반 일부 드라이버는 표준 라이브러리에 분석기를 적용하지 않으므로, 분석기 작성자는 표준 패키지의 Fact가 항상 존재하리라 가정해서는 안 된다. 실제로 printf 체커는 log.Printf가 printf 래퍼라는 사실을 분석으로 도출할 수 있음에도, 그 사실을 분석기에 내장해 두어 표준 라이브러리를 분석하지 않는 드라이버에서도 정확히 동작하게 만들었다.
마지막으로 도구를 만드는 부담을 줄여 주는 하위 패키지들이 있다. analysistest는 testdata 파일 위에서 분석기를 돌려 '// want ...' 주석으로 표현한 기대 진단과 Fact가 정확히 일치하는지 몇 줄로 검증하게 해 준다. singlechecker는 분석기 하나를 실행하는 명령의 main 함수를, multichecker는 여러 분석기를 묶은 도구를 사실상 몇 줄 코드로 만들어 준다. Validate 함수는 이름이 유효한 식별자인지, Doc이 비어 있지 않은지, Requires 그래프가 비순환인지, Fact 타입이 유일하고 포인터인지 등 기본적인 정합성을 점검한다. 자체 검사 규칙을 팀 표준으로 배포하고 싶은 조직이라면, vet에 기능을 끼워 넣기보다 이 구조 위에 자신들의 분석기와 전용 명령을 두는 편이 유지보수와 재사용 양쪽에서 유리하다는 점이 이 프레임워크가 주는 실질적 교훈이다.