Log Stash

as an Industrial Personnel

프로그래밍

Roslyn으로 C# 코드 정적 분석하기

SavvyTuna 2018. 3. 5. 01:59

1. 상황

현재 나는 유니티 엔진으로 개발하는 게임 프로젝트팀에서 일하고 있고, 요즘 게임들이 그렇듯 이 게임도 서버와 HTTP위에서 json텍스트를 주고 받으며 통신한다. 서버로부터 json텍스트를 받으면, 텍스트안에 명시된 데이터 타입의 이름으로 그에 맞는 deserializer 메소드를 찾아 해당 데이터 타입 오브젝트로 변환해주는 방법을 사용하고 있다. (몇몇 데이터의 구조가 약간 복잡해서 바로 리플렉션 시켜줄 수는 없음)

현재 쓰고 있는 json 라이브러리는, 파싱할 때 json 오브젝트는 Hashtable로, json 배열은 ArrayList로, 문자열은 string으로, 그리고 숫자 타입은 무조건 float 으로 만들어서 반환해주고 있었고, 이렇게 파싱된 자료구조를 다시 데이터 오브젝트 객체의 멤버 변수로 전달할 때 아래처럼 하고 있었다.

/* json parse */
Hashtable hashtable = JSON.parse(jsonText);

/* deserialize method */
int money = (int)(float)hashtable["mymoney"];
float speed = (float)hashtable["speed"];

이렇게 잘(?) 써오고 있다가 드디어 정밀도 문제가 터지게 되었고, float보다 높은 정밀도의 타입(일단 double이라고 생각하자)으로 파싱하도록 json 라이브러리와 그 라이브러리가 파싱한 자료구조들을 써먹고있는 deserializer부분을 수정해야 했었다.

첫 번째로, json 라이브러리를 수정하는건 매우 간단한 일이었다. 유니티엔진의 라이브러리들은 보통 소스코드가 공개되어있는 경우가 대부분인데 이것도 그랬다. 덕분에 비효율적으로 작성되어있는 부분을 예전에 우리 프로젝트에 맞게 뜯어고치기도 했었고.

문제는 모든 데이터 오브젝트의 deserializer부분을 고쳐야 한다는 점이었다. 얼핏 봐선 json 라이브러리가 숫자 텍스트를 파싱해서 double값을 만들어내고, 그걸 Hashtable안에 넣어주더라도, 위에 보이는 기존의 deserialize 로직에서 충분히 double에서 float으로 다운 캐스팅 타입 변환을 해줄것처럼 보인다. 하지만 그렇지 않고 InvalidCastException이 뜬다. 왜냐면 hashtable["mymoney"] 처럼 Hashtable에서 인덱스 연산으로 가져온 원소의 타입은 object고, 이 object타입의 변수 앞에 붙은 cast expression은 타입 변환(conversion)이 아니라 그냥 언박싱(unbox)이기 때문에 해당 object의 타입을 정확히 맞춰줘야 한다. 즉 float을 boxing해서 오브젝트로 집어넣었으면 꺼낼때도 정확히 float으로 꺼내야 한다는 말.(ArrayList도 object를 반환 하기 때문에 마찬가지)

결국 이 프로젝트 곳곳에 숨어있는 수 천개의 (float) 캐스팅 연산 중에서, deserializer 로직 안에있는, json 파싱 라이브러리가 내뱉은 자료구조를 deserialize 하는 부분만 찾아서 (double)로 고쳐줘야 한다는 말이었다.

물론 팀 내에서 다 같이 최대한 기존의 파싱로직을 고치지 않는 쪽으로, 다른 방법을 두 개정도 더 생각해봤었지만. 아래에서 설명할 방법으로 그냥 기존 로직을 수정하는것으로 정했다. (사실 trade-off가 다 비슷비슷해서 그냥 작업자가 원하는 방법으로 수정하기로 결정했고. 그 작업자가 나였다.)

2. 어떻게 바꿀것인가

개인적으로 이렇게 기존 코드에서 사용처가 많은 부분을 수정해야 했던 리팩토링을 어느정도 해봤었는데. 이때 바꿔야 할 코드 조각들을 찾아내기 위해, 최대한 코드들의 공통점을 찾아서 정규식을 만들어 솔루션 전체 탐색으로 찾아내곤 했었다. 하지만 이번엔 그렇게 할 수도 없었다. 일단 "(float)" 만으로 검색하면 당연히 모든 float 캐스팅이 전부 검색될테니 당연히 그렇게 하면 안된다. deserialize할 때 사용되는 hashtable 변수의 이름을 보통 h로 시작하게 만들곤 했는데, 그렇다고 "(float)\s*h" 로 검색할 수도 없다. 변수 이름을 그렇게 짓지 않는 부분도 굉장히 많고, 따로 object형 임시 변수에 저장해서 변환하는 경우는 잡을 수 없고, arrayList는 또 그렇게 이름을 짓지 않기 때문에 얘네들도 잡을 수 없다. 결국, float cast expression에서 오른쪽이 object타입의 expression이라는 semantic 정보가 필요하다.

다행히도 C#에는 소스 코드들의 syntax tree나 semantic정보들처럼 컴파일러 내부에서 생성되는 정보들을 받아 볼 수 있게 해주는 Roslyn이라는 툴이 있다. 이를 이용해서 코드 정적분석을 해주는 extension도 세상에 많이 나와 있고. (예: ClrHeapAllocationAnalyzer ) 이 Roslyn을 이용해서 'cast expression의 오른쪽 expression의 type이 hashtable의 element access expression을 사용해서 나온 object 인지' 를 판단하고, 이를 경고나 오류로 지적해주는 정적 분석 규칙 을 만들어서 바꾸기로 했고. 결과를 먼저 말 하자면, 잘 됐다. (추가적으로 몇 가지 규칙을 더 만들긴 했음.)

3. Roslyn

사실 syntax tree랑 semantic 데이터들이 주는 정보가 하도 많아서 문법적 특징을 잡아내는것은 어렵지 않았는데 (그렇게 복잡한 문법을 잡아내는게 아니었으니), 그것보다 설치하고 프로젝트 만드는 것이 더 헷갈렸다. 그래서 그거부터 적어본다.

비주얼 스튜디오 버전은 2015를 기준으로 한다. 회사에서 그걸 쓰기 때문.

3.1. Install

  1. (비주얼 스튜디오가 없으면 깔고) 제어판 > 프로그램 추가 제거에 들어가서 vs 2015를 선택하고 '변경'을 눌러 인스톨러를 실행시킨다.


  1. https://github.com/dotnet/roslyn/wiki/Getting-Started-on-Visual-Studio-2015 여기 들어가서 깔라는대로 체크박스 선택해서 설치하자.

  2. (비주얼 스튜디오에서) New Project > Installed > Templates > Visual C# > Extensibility에서 'Download the .NET Compiler Platform SDK'를 선택하고 웹 사이트를 쭉 따라가서 주어지는 .vsix 파일을 설치하자.

3.2. 프로젝트 생성

.vsix 파일을 설치 했으면 아까 그 Extensibility 항목에 'Analyzer with Code Fix (NuGet + VSIX)' 템플릿이 생겼을 수도 있고 아닐수도 있다. 생겼으면 그거 선택하면 된다. 나는 아무리 설치해도 안 생기길래 찾아보니 왼쪽 위에 있는 닷넷 프레임워크 버전을 바꿔보랜다. 바꿔봤더니 생겼다.

참고로 위의 'stand alone code analysis tool' 프로젝트는 아예 c# 코드를 string 변수로 (알아서) 받아오고, 그걸 파싱하는 콘솔 프로그램 프로젝트고, 'Analyzer with Code Fix''이런 이런 문법을 잡아서 이렇게 고쳐라' 라는 로직을 작성하면 비주얼 스튜디오에 extension으로 붙어서 정적분석 시간에 지정된 로직을 수행하는 프로젝트다. 궁금하면 해보시길.

3.3. Analyzer 작성

tools

analyer를 작성할때 첫번째로 필요한것은, 찾아내고자 하는 구문이 어떤 문법적 구조를 가지고 있는지 파악하는것이다. 이때 아까 설치한 .vsix파일로 설치되는 몇 가지 컴파일러 관련 도구들이 매우 유용하다.

우선 'Syntax Visualizer' 라는 툴이 있는데, 이 툴은 현재 커서가 위치한 곳의 syntax tree가 어떤 구조로 만들어질지, 관련 semantic 정보들이 뭐가 있을지 한 눈으로 보여주는 툴이다. 이를 이용해서 실제로 어떤 클래스의 멤버들이 어떤 값을 가지는지 적당히 눈으로 볼 수 있다.

또한 Syntax Visualizer에서 특정 코드에 대한 directed graph를 만들어서 쉽게 syntax tree 구조를 파악할 수 있다.


project

프로젝트를 생성하면 솔루션 안에 크게 3가지의 프로젝트가 생성되는데,

  • Analyzer (프로젝트 이름 따라 갈것임. 나는 프로젝트 이름이 Analyzer였다)
  • Analyzer.Text (이름만 봐도 알겠지만 테스트 프로젝트. 유닛 테스트들을 작성할 수 있다)
  • Analyzer.Vsix (Vsix 빌드 프로젝트. 이 프로젝트를 StartUp 프로젝트로 설정해둬야 한다)

나는 Analyzer 프로젝트만 작성했는데, 테스트를 작성하고 싶으면 위에 언급된 'ClrHeapAllocationAnalyzer' 프로젝트를 참고하면된다.

갓 만들어진 Analyzer 프로젝트를 보면 두 개의 C# 클래스가 만들어져 있는데, ~Analyzer는 문법을 잡아내는 규칙(Rule)이 들어가야 하는 클래스고, CodeFixProvider는 잡아낸 규칙에 따라 코드를 어떻게 수정할지에 대한 내용이 들어가는 클래스다. (참고로 잡아낸 규칙을 꼭 수정해야 할 필요는 없다. 그냥 잡아내서 보여주기만 해도 됨.)

나 같은 경우는 위에서 말했듯 float 캐스팅에서 오른쪽에 오는게 hashtable인 부분을 찾는 로직을 작성하기로 했다.

// Analyzer.cs 일부분

public override void Initialize(AnalysisContext context)
{
    // 초기화 시점에 CastExpression을 만나면 검사할 로직(AnalyzeCast)를 부착.
    context.RegisterSyntaxNodeAction(AnalyzeCast, SyntaxKind.CastExpression);
}

private static void AnalyzeCast(SyntaxNodeAnalysisContext context)
{
    // Context variables
    SyntaxNode           node = context.Node;
    SemanticModel        semanticModel = context.SemanticModel;
    CastExpressionSyntax castExpr = node as CastExpressionSyntax;

    // Type symbols
    INamedTypeSymbol hashtableType = semanticModel.Compilation.GetTypeByMetadataName("System.Collections.Hashtable");
    INamedTypeSymbol arraylistType = semanticModel.Compilation.GetTypeByMetadataName("System.Collections.ArrayList");
    INamedTypeSymbol objectType    = semanticModel.Compilation.GetTypeByMetadataName(typeof(object).FullName);

    // 리폿 조건
    bool isFloatCast      = false; // float casting
    bool isHashtable      = false; // from Hashtable or ArrayList
    bool isArrayList      = false;
    bool isObjectTypeExpr = false;

    // 1. float 캐스트인지?
    if (castExpr.Type is PredefinedTypeSyntax)
    {
        var castType = castExpr.Type as PredefinedTypeSyntax;
        if (castType.Keyword.IsKind(SyntaxKind.FloatKeyword))
        {
            isFloatCast = true;
        }
    }

    // 2. Hashtable로부터 파생된 object 타입의 expression인지?
    if (castExpr.Expression is ElementAccessExpressionSyntax)
    {
        var elemExpr = castExpr.Expression as ElementAccessExpressionSyntax;

        // 2.1. element access의 대상이 되는 identifier가 hashtable인지?
        var elemExprEvalType = semanticModel.GetTypeInfo(elemExpr.Expression);
        isHashtable = elemExprEvalType.Equals(hashtableType);
        isArrayList = elemExprEvalType.Equals(arraylistType);
    }

    // 3. castExpr의 expression 자체 타입이 object인지? (그래야 unboxing일 테니)
    ITypeSymbol elemExprType = semanticModel.GetTypeInfo(castExpr.Expression).ConvertedType;
    if (elemExprType.Equals(objectType))
    {
        isObjectTypeExpr = true;
    }

    // 리폿
    if (isFloatCast && (isHashtable || isArrayList) && isObjectTypeExpr)
    {
        // 여기서 `Rule`은 DiagnosticDescriptor 타입.
        var diagnostic = Diagnostic.Create(Rule, node.GetLocation());
        context.ReportDiagnostic(diagnostic);
    }
}

3.4. 돌려보기

처음엔 .vsix 파일을 어떻게 빌드해서 비주얼 스튜디오에 붙이고 디버깅 해야하나 걱정했는데, 그럴 필요없다. 보통 비주얼 스튜디오 프로젝트 빌드하듯 ctrl + f5를 눌러서 실행시키면 새로운 비주얼 스튜디오 인스턴스가 하나 더 뜨는데, 이 인스턴스에는 빌드된 .vsix가 적용되어있다. 여기에 빈 프로젝트를 하나 만들어서 예시 코드를 작성하든, 기존에 작업하던 솔루션을 열어서 확인해보든 하면 된다.

참고로 최초로 이런 .vsix를 빌드 & 실행시켜서 새로운 인스턴스를 최초로 띄우는 경우, 비주얼 스튜디오를 설치하고 맨 처음 실행시킨 것 처럼 테마 설정하고 언어 환경 설정하는 창이 뜬다. 이때 현재 사용하고 있는 메인 테마랑 다른 테마로 설정하는게 정신건강에 좋다.


코드 정적분석 하길 원하는 솔루션(또는 프로젝트)를 열어놓고 위 그림 처럼 '코드 분석 실행'을 누르면, 일단 syntax error가 있는지 없는지 빌드를 한번 한다. 그 다음에 기다리다보면 비주얼 스튜디오가 코드를 차례차례 분석하는대로 오류(경고)들이 하나씩 올라온다. (좀 오래 걸린다. UI를 블로킹하고 분석을 하는게 아니라 그런지 그냥 아무일도 안 하는것 처럼 보임.)

원하는 부분만 잡아낼 때 까지 디버깅을 하며 로직을 수정하자.

3.5. CodeFixer 작성

이렇게 해서 발견된 float 캐스팅이 대충 900개 정도 있었다. 이 900군데를 다 돌아다니면서 코드를 수정하긴 싫으니 CodeFixer를 작성했다.

C# 코드를 작성하다보면, 종종 문법에 틀린 부분을 비주얼 스튜디오에서 잡아내고, 빨간색 줄을 쳐서 틀렸다고 말해주는 동시에, 어떻게 고칠 수 있는지에 대해서 미리 보여주고, 일괄 수정 할 수 있다. 여기에서 잡아내는 부분이 아까 위에서 한 Analyzer가 하는 일이고, CodeFixer가 하는일은 이 문제점을 어떻게 고칠 수 있는지, 수정 방안을 제시하는 것이다.


나는 일단 간단하게 (float)과 hashtable에서 나온 object 사이에, (double) 캐스팅을 추가하는 방향으로 CodeFixer를 작성했다. (실제로 회사에서 한 수정 방식과는 좀 다르다)

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
    var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
    
    var diagnostic = context.Diagnostics.First();
    var diagnosticSpan = diagnostic.Location.SourceSpan;

    // Find the cast expression identified by the diagnostic.
    CastExpressionSyntax castExpr = root.FindToken(diagnosticSpan.Start).Parent as CastExpressionSyntax;

    // Create a Code action that will invoke the fix.
    CodeAction action = CodeAction.Create(
        title: title,
        createChangedDocument: c => InsertCast(context.Document, castExpr, c),
        equivalenceKey: title);

    // Register the code action.
    context.RegisterCodeFix(action, diagnostic);
}

private async Task<Document> InsertCast(Document document, CastExpressionSyntax castExpr, CancellationToken cancellationToken)
{
    // Cast token and type to be inserted.
    SyntaxToken castToken = SyntaxFactory.Token(SyntaxKind.DoubleKeyword);
    PredefinedTypeSyntax castType = (castExpr.Type as PredefinedTypeSyntax).WithKeyword(castToken);

    CastExpressionSyntax subCastExpr = SyntaxFactory.CastExpression(castType, castExpr.Expression);

    // replace 'Expression'
    CastExpressionSyntax newCastExpr = castExpr.WithExpression(subCastExpr);

    SyntaxNode oldRoot = await document.GetSyntaxRootAsync(cancellationToken);
    SyntaxNode newRoot = oldRoot.ReplaceNode(castExpr, newCastExpr);

    return document.WithSyntaxRoot(newRoot);
}

이렇게 해서 900개 정도 되는 곳의 float 캐스팅 사이에 double을 끼워넣는 작업을 일괄처리 할 수 있었다. 좀 더 나아가자면 앞에 추가적으로 더 캐스팅이 있는경우엔 ((int)(float) 같은 경우) 최상위 CastExpression 까지 따라가서 목적 타입과 double만 남겨놓을 수도 있었겠지만 일단 여기까지.

물론 실제 프로젝트의 코드에선 이것 말고도 수정해야할 케이스들이 더 있어서 몇 가지 규칙을 더 만들어서 수정했다.

4. References

  • https://github.com/dotnet/roslyn/wiki/Getting-Started-on-Visual-Studio-2015
  • https://github.com/dotnet/roslyn/wiki/Getting-Started-C%23-Syntax-Analysis
  • https://github.com/dotnet/roslyn/wiki/How-To-Write-a-C%23-Analyzer-and-Code-Fix