Roslyn이란 무엇인가 ── 컴파일러의 시각으로 C# 코드를 읽고 고치고 생성하기

· · .NET, C#, Roslyn, Analyzer, SourceGenerator, Compiler, StaticAnalysis, CodeGeneration, 기존 자산 활용

1. 가장 먼저 알아둘 것

C# 소스 코드를 처리하고 싶은 상황은 생각보다 많습니다. 예를 들면 이런 작업입니다.

특정 API의 사용법을 금지하고 싶다
오래된 작성 방식을 기계적으로 찾고 싶다
메서드나 클래스의 목록을 수집하고 싶다
프로젝트 전체에서 의존 관계를 조사하고 싶다
정형화된 코드를 컴파일 시점에 생성하고 싶다
자사 라이브러리의 오용을 빌드 시점에 경고하고 싶다
대규모 치환이나 마이그레이션을 안전하게 진행하고 싶다

이럴 때는 단순히 *.cs 파일을 열어 문자열 검색이나 정규 표현식으로 처리하고 싶어지는 경우가 있습니다.

하지만 C#은 문자열이 아닙니다. 다음 두 코드는 겉보기에는 비슷해도, 의미상으로는 서로 다른 것입니다.

Console.WriteLine("Hello");
MyCompany.Logging.Console.WriteLine("Hello");

또한 Console이라는 이름이 다른 타입을 가리키는 경우도 있습니다.

using Console = MyCompany.Logging.Console;

Console.WriteLine("Hello");

문자열로 보면 어느 쪽이든 Console.WriteLine처럼 보입니다. 하지만 컴파일러 입장에서 보면, 그것이 System.Console.WriteLine인지 다른 타입인지는 이름 해석(name resolution)을 하지 않으면 알 수 없습니다.

여기서 등장하는 것이 Roslyn입니다. Roslyn은 C#이나 Visual Basic 컴파일러가 가진 정보를, 애플리케이션이나 도구에서 API로 이용할 수 있게 해 주는 기반입니다.

간단히 말하면, Roslyn을 사용함으로써 C# 코드를 다음과 같이 다룰 수 있게 됩니다.

문자열이 아니라 구문으로 읽는다
겉모습이 아니라 의미로 읽는다
단일 파일이 아니라 프로젝트나 솔루션으로 읽는다
읽은 결과를 바탕으로 경고·수정안·생성 코드를 낸다

이 글에서는 Roslyn의 전체상, Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, 그리고 실무에서의 활용처를 정리합니다.

또한 이 글에 등장하는 코드는, 빌드·실행할 수 있는 샘플 세트(Syntax Tree / SemanticModel을 다루는 라이브러리, DateTime.Now를 경고하는 Analyzer, Source Generator, 솔루션 전체를 분석하는 데모, 오탐/미탐지를 확인하는 유닛 테스트)로 GitHub에 공개하고 있습니다.

roslyn-dotnet-compiler-platform - komurasoft-blog-samples (GitHub)

2. Roslyn이란 무엇인가

Roslyn은 정식으로 .NET Compiler Platform이라고 불립니다. C#과 Visual Basic의 컴파일러 구현이며, 동시에 코드 분석 도구를 만들기 위한 API 모음이기도 합니다.

예전에는 컴파일러가 이런 ‘블랙박스’로 취급되는 경우가 많았습니다.

소스 코드를 넣는다
컴파일러가 처리한다
DLL / EXE가 나온다

개발자가 컴파일러 내부에서 만들어지는 정보에 접근하는 것은, 보통은 할 수 없었습니다.

하지만 실제 컴파일러는 단순히 텍스트를 기계어나 IL로 변환하는 것이 아니라, 컴파일 중에 다음과 같은 정보를 만들어 냅니다.

이 문자열은 클래스 선언이다
이 식별자는 로컬 변수다
이 메서드 호출은 이 타입의 이 메서드를 가리킨다
이 식의 반환값 타입은 string이다
이 코드는 구문 오류다
이 참조는 어셈블리 A의 타입을 가리킨다
이 using은 실제로는 사용되지 않는다

Roslyn은 이러한 정보를 개발자가 이용할 수 있게 해 줍니다. 그래서 Roslyn은 단순한 컴파일러가 아니라, 코드를 이해하기 위한 플랫폼입니다.

3. Roslyn을 사용하면 무엇을 할 수 있는가

Roslyn을 사용하면 주로 다음과 같은 것을 할 수 있습니다.

C# / VB의 구문 분석
타입이나 메서드의 의미 분석
컴파일 정보 취득
프로젝트나 솔루션 전체의 분석
자체 Analyzer 작성
Code Fix 작성
Source Generator 작성
리팩터링 도구 작성
코드 생성
코드 변환

좀 더 실무에 가깝게 쓰면, 이런 사용법입니다.

금지된 API를 사용하면 빌드 경고로 만든다
오래된 API를 사용하는 위치를 목록화한다
async 메서드의 명명 규칙을 검사한다
IDisposable 처리 누락을 검출한다
자사 프레임워크의 올바른 사용법을 유도한다
DTO나 매핑 코드를 컴파일 시점에 생성한다
설정 파일이나 속성(attribute)에서 정형 코드를 생성한다
.NET Framework에서 .NET으로의 마이그레이션 조사를 보조한다

Roslyn이 중요한 이유는, ‘C# 소스 코드를 다루는 처리’를 컴파일러와 같은 토대 위에서 작성할 수 있다는 점입니다.

정규 표현식이나 자체 파서로 C#을 읽으면 금방 한계에 부딪힙니다. 예를 들어 다음과 같은 요소를 올바르게 다루는 것은 쉽지 않습니다.

using alias
확장 메서드
partial class
partial method
global using
nullable annotations
제네릭 타입
오버로드 해결
조건부 컴파일
전처리기 지시문
주석이나 공백을 유지한 재작성

Roslyn은 이러한 요소들을 C# 언어 명세에 따라 다루기 위한 API를 제공합니다.

4. Roslyn은 ‘구문’과 ‘의미’를 구분해서 생각한다

Roslyn을 이해할 때, 가장 먼저 구분해 두어야 할 것이 다음 두 가지입니다.

구문: 코드가 어떻게 작성되어 있는가
의미: 그 코드가 무엇을 가리키는가

예를 들어 다음 코드를 봅니다.

Console.WriteLine(message);

구문으로 보면 이런 형태입니다.

식문
  호출식
    멤버 액세스식
      식별자 Console
      식별자 WriteLine
    인수 message

하지만 이것만으로는 의미를 알 수 없습니다. Console이 어떤 타입인지, WriteLine이 어떤 오버로드인지, message의 타입이 무엇인지는 구문만으로는 판단할 수 없습니다.

의미로 보려면 다음과 같은 정보가 필요합니다.

using 상태
참조 어셈블리
같은 프로젝트 내의 타입 정의
다른 프로젝트에 대한 참조
타입 추론
오버로드 해결
언어 버전
nullable context

Roslyn에서는 이 차이가 API로도 나뉘어 있습니다.

Syntax Tree     : 코드의 구문을 나타낸다
SemanticModel   : 구문이 무엇을 의미하는지를 나타낸다
Compilation     : 컴파일에 필요한 전체 정보를 나타낸다
Workspace       : 솔루션, 프로젝트, 문서를 다룬다

이 구분을 파악해 두면 Roslyn을 훨씬 잘 이해할 수 있습니다.

5. Syntax Tree란 무엇인가

Syntax Tree는 소스 코드의 구문 구조를 나타내는 트리입니다. 예를 들어 다음과 같은 코드가 있다고 합시다.

class User
{
    public string Name { get; set; }

    public void Rename(string name)
    {
        Name = name;
    }
}

이 코드는 Roslyn의 관점에서 보면 대략 이런 구조입니다.

CompilationUnit
  ClassDeclaration: User
    PropertyDeclaration: Name
    MethodDeclaration: Rename
      Parameter: name
      Block
        ExpressionStatement
          AssignmentExpression

Syntax Tree는 텍스트를 단순히 줄 단위로 나눈 것이 아니라, 클래스, 메서드, 프로퍼티, 식, 문(statement), 인수, 연산자 등 C#의 구문 요소로 정리된 구조입니다.

간단한 예를 봅니다.

using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

var source = """
class User
{
    public string Name { get; set; }

    public void Rename(string name)
    {
        Name = name;
    }
}
""";

var tree = CSharpSyntaxTree.ParseText(source);
var root = tree.GetCompilationUnitRoot();

var methods = root
    .DescendantNodes()
    .OfType<MethodDeclarationSyntax>();

foreach (var method in methods)
{
    Console.WriteLine(method.Identifier.Text);
}

이 코드는 소스 코드 안에서 메서드 선언을 찾아 메서드 이름을 출력합니다. 이 예에서는 Rename을 얻을 수 있습니다.

문자열 검색으로 void를 찾는 것이 아니라, C#의 구문으로서 ‘메서드 선언’을 찾고 있다는 점이 중요합니다.

6. Node, Token, Trivia

Syntax Tree를 다룰 때는 다음 세 가지 용어가 자주 등장합니다.

SyntaxNode
SyntaxToken
SyntaxTrivia

SyntaxNode

SyntaxNode는 구문상의 하나의 묶음입니다. 예를 들면 이런 것들입니다.

클래스 선언
메서드 선언
프로퍼티 선언
if문
for문
대입식
호출식
람다식

C#의 구문상 자식 요소를 더 가지는 것이 Node입니다.

SyntaxToken

SyntaxToken은 구문을 구성하는 최소 단위입니다. 예를 들면 이런 것들입니다.

class 키워드
public 키워드
식별자 User
식별자 Rename
{ 나 }
; 나 ,
문자열 리터럴
숫자 리터럴

Token은 구문 트리의 말단에 있는 요소입니다.

SyntaxTrivia

SyntaxTrivia는 일반적인 의미 분석에는 직접 관계하지 않지만, 소스 코드를 그대로 재현하기 위해서는 필요한 정보입니다. 예를 들면 이런 것들입니다.

공백
줄바꿈
주석
전처리기 지시문

이 Trivia가 있기 때문에 Roslyn은 주석이나 공백까지 포함해 소스 코드를 높은 충실도로 다룰 수 있습니다.

코드 정렬, 리팩터링, 기계적인 재작성을 할 때 Trivia는 매우 중요합니다.

단순히 AST를 만드는 것뿐이라면 주석을 버려도 괜찮을 것처럼 보입니다. 하지만 실무의 코드 변환에서는 주석이나 줄바꿈을 깨뜨리지 않는 것이 중요합니다.

7. Syntax Tree는 불변이다

Roslyn의 Syntax Tree는 불변(immutable)입니다. 즉, 얻은 구문 트리를 직접 고치는 것이 아니라, 변경을 가한 새로운 구문 트리를 만듭니다.

예를 들어 메서드 이름을 바꾸고 싶은 경우에도, 기존의 MethodDeclarationSyntax를 그 자리에서 변경하는 것이 아닙니다.

var newMethod = oldMethod.WithIdentifier(
    SyntaxFactory.Identifier("NewName"));

이렇게 새로운 노드를 만듭니다.

불변이라는 점에는 몇 가지 이점이 있습니다.

여러 스레드에서 다루기 쉽다
IDE에서 편집 중인 스냅숏을 안전하게 다룰 수 있다
차이(diff)를 만들기 쉽다
변경 전과 변경 후를 비교하기 쉽다

처음에는 조금 번거롭게 느껴질 수 있습니다. 하지만 IDE, 빌드, Analyzer, Source Generator처럼 같은 코드를 여러 처리가 동시에 참조하는 세계에서는, 불변성이 큰 이점이 됩니다.

8. SemanticModel이란 무엇인가

Syntax Tree만으로는 코드의 겉모습밖에 알 수 없습니다. 의미를 알려면 SemanticModel을 사용합니다.

예를 들어 다음 코드를 생각해 봅니다.

Console.WriteLine("Hello");

Syntax Tree만으로는 Console이라는 식별자와 WriteLine이라는 식별자가 있다는 것은 알 수 있습니다. 하지만 그것이 System.Console.WriteLine(string?)을 가리키는지, 다른 타입의 메서드를 가리키는지는 알 수 없습니다.

SemanticModel을 사용하면 ‘이 구문 노드가 어떤 심볼로 해석되었는가’를 얻을 수 있습니다.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

var source = """
using System;

class Program
{
    static void Main()
    {
        Console.WriteLine("Hello");
    }
}
""";

var tree = CSharpSyntaxTree.ParseText(source);

var compilation = CSharpCompilation.Create(
    assemblyName: "Sample",
    syntaxTrees: new[] { tree },
    references: new[]
    {
        MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
        MetadataReference.CreateFromFile(typeof(Console).Assembly.Location)
    });

var semanticModel = compilation.GetSemanticModel(tree);
var root = tree.GetCompilationUnitRoot();

var invocation = root
    .DescendantNodes()
    .OfType<InvocationExpressionSyntax>()
    .First();

var symbolInfo = semanticModel.GetSymbolInfo(invocation);
var method = (IMethodSymbol?)symbolInfo.Symbol;

Console.WriteLine(method?.ContainingType.ToDisplayString());
Console.WriteLine(method?.Name);

이렇게 하면 Console.WriteLine이 실제로 어떤 메서드로 해석되었는지를 얻을 수 있습니다.

구문뿐 아니라 컴파일러의 이름 해석 결과를 이용할 수 있다는 것이 Roslyn의 강점입니다.

9. Symbol이란 무엇인가

Roslyn에서는 타입, 메서드, 프로퍼티, 필드, 인수, 로컬 변수 등을 Symbol로 다룹니다.

대표적인 인터페이스를 들어 보겠습니다.

INamedTypeSymbol  : 클래스, 구조체, 인터페이스 등
IMethodSymbol     : 메서드
IPropertySymbol   : 프로퍼티
IFieldSymbol      : 필드
IParameterSymbol  : 인수
ILocalSymbol      : 로컬 변수
INamespaceSymbol  : 네임스페이스

Symbol은 소스 코드상의 겉모습이 아니라, 컴파일러가 해석한 의미를 나타냅니다.

예를 들어 다음 두 코드는 겉모습이 다릅니다.

System.Console.WriteLine("Hello");
using System;

Console.WriteLine("Hello");

하지만 둘 다 같은 System.Console.WriteLine을 가리킨다면, Roslyn의 의미 분석에서는 같은 메서드 심볼로 다룰 수 있습니다.

이 성질 덕분에 다음과 같은 판정을 할 수 있습니다.

이 호출이 정말로 자사에서 금지하고 있는 API인가
이 타입이 특정 인터페이스를 구현하고 있는가
이 메서드는 async인가
이 반환값은 nullable인가
이 속성(attribute)이 실제로 붙어 있는가
이 클래스가 특정 기반 클래스를 상속하고 있는가

단순한 문자열 검색이 아니라, 컴파일러의 판단에 기반한 분석을 할 수 있습니다.

10. Compilation이란 무엇인가

Compilation은 C#이나 Visual Basic 프로그램을 컴파일하기 위해 필요한 정보를 모아 놓은 것입니다.

구체적으로는 다음과 같은 정보를 가집니다.

SyntaxTree의 집합
참조 어셈블리
컴파일 옵션
언어 버전
정의된 심볼
타입이나 멤버의 정보
진단 정보

단일 파일을 구문으로만 읽는다면 SyntaxTree로 충분합니다. 하지만 타입 해석이나 참조 해석을 하려면 Compilation이 필요합니다.

예를 들어 다음과 같은 것을 하고 싶은 경우입니다.

이 메서드 호출이 어떤 타입의 메서드인지 알고 싶다
이 클래스가 IDisposable을 구현하고 있는지 알고 싶다
이 속성이 실제로 어떤 속성 타입인지 알고 싶다
이 식의 반환값 타입을 알고 싶다
컴파일 오류나 경고를 얻고 싶다

이것들은 구문만으로는 판단할 수 없습니다. 프로젝트의 참조나 컴파일 옵션까지 포함해서 생각할 필요가 있습니다.

11. Workspace란 무엇인가

단일 파일이 아니라 솔루션이나 프로젝트 전체를 다루고 싶은 경우에는 Workspace를 사용합니다.

Workspace는 다음과 같은 단위를 다룹니다.

Solution
Project
Document

예를 들어 솔루션 전체에서 모든 프로젝트를 로드하고, 모든 문서를 분석하는 경우입니다.

using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis.MSBuild;

MSBuildLocator.RegisterDefaults();

using var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync("Sample.sln");

foreach (var project in solution.Projects)
{
    Console.WriteLine(project.Name);

    foreach (var document in project.Documents)
    {
        var root = await document.GetSyntaxRootAsync();
        Console.WriteLine($"  {document.Name}: {root?.DescendantNodes().Count()} nodes");
    }
}

이런 도구를 만들면 기존 코드베이스의 조사나 마이그레이션 지원에 사용할 수 있습니다.

예를 들어 다음과 같은 용도입니다.

특정 API 호출 목록을 CSV로 만든다
오래된 네임스페이스의 사용 위치를 목록화한다
public API의 목록을 만든다
프로젝트 간 의존 관계를 조사한다
거대한 솔루션의 코드 규약 위반을 조사한다
기계적인 코드 변환을 수행한다

Analyzer는 IDE나 빌드와 일체로 동작하는 방식입니다. 반면, Workspace를 사용한 콘솔 도구는 조사나 일괄 마이그레이션에 적합합니다.

이 두 가지는 비슷하지만, 용도를 구분해서 사용하는 것이 좋습니다.

12. Roslyn을 사용하는 대표적인 형태

Roslyn의 사용법은 크게 나누면 4가지입니다.

1. 라이브러리로 사용한다
2. Analyzer를 만든다
3. Code Fix를 만든다
4. Source Generator를 만든다

각각 목적이 다릅니다.

라이브러리로 사용하기

직접 만든 콘솔 앱이나 사내 도구에서 Roslyn API를 호출합니다.

적합한 용도는 이런 것들입니다.

코드베이스 조사
일괄 변환
메트릭 수집
마이그레이션 지원
리포트 작성

이 형태에서는 원하는 시점에 도구를 실행할 수 있습니다. IDE에서 입력하는 중에 동작할 필요가 없기 때문에, 어느 정도 무거운 처리도 허용하기 쉽습니다.

Analyzer 만들기

Analyzer는 코드를 분석해서 경고나 오류를 내는 방식입니다.

예를 들어 다음과 같은 규칙을 만들 수 있습니다.

DateTime.Now를 쓰지 않고 DateTimeOffset.UtcNow를 사용한다
async 메서드 이름은 Async로 끝난다
라이브러리의 초기화 API를 잘못된 순서로 호출하지 않는다
특정 namespace를 신규 코드에서 사용하지 않는다
Task.Result / Wait의 사용을 금지한다

Analyzer는 Visual Studio나 빌드 시점에 동작시킬 수 있습니다. 팀의 규약이나 라이브러리 사용법을, 리뷰 담당자의 기억에 의존하지 않고 기계적으로 검출할 수 있습니다.

Code Fix 만들기

Code Fix는 Analyzer가 찾은 문제에 대해 수정안을 제시하는 방식입니다.

Visual Studio의 전구 아이콘에서 적용할 수 있는 수정을 떠올리면 이해하기 쉽습니다.

예를 들어 Analyzer가 다음 코드를 검출했다고 합시다.

DateTime.Now

Code Fix로 이런 수정을 제시할 수 있습니다.

DateTimeOffset.UtcNow

단순히 ‘경고를 내는’ 것뿐만 아니라 ‘안전한 고치는 방법’을 자동화할 수 있다는 것이 Code Fix의 강점입니다.

Source Generator 만들기

Source Generator는 컴파일 시점에 코드를 생성하고, 그 코드를 같은 컴파일에 추가하는 방식입니다.

예를 들어 다음과 같은 용도가 있습니다.

속성(attribute)이 붙은 클래스에서 정형 코드를 생성한다
설정 파일에서 타입 안전한 접근자(accessor)를 생성한다
DTO의 매핑 코드를 생성한다
시리얼라이저용 코드를 생성한다
enum과 문자열 간의 변환 코드를 생성한다
라우팅이나 DI 등록 코드를 생성한다

실행 시에 Reflection으로 정보를 모으던 처리를, 컴파일 시점의 생성 코드로 대체할 수 있는 경우가 있습니다. 이를 통해 시작 비용 절감이나 AOT와의 호환성 개선으로 이어질 수 있습니다.

13. Analyzer는 ‘자동 코드 리뷰’로 활용할 수 있다

Analyzer는 실무에서 ‘자동 코드 리뷰’라고 생각하면 이해하기 쉽습니다.

코드 리뷰에서는 매번 같은 지적이 나오는 경우가 있습니다.

이 API는 사용하지 마세요
이 메서드 이름은 규약에 맞지 않습니다
이 catch는 예외를 삼키고 있습니다
이 null 체크는 불필요합니다
이 호출은 성능상 문제가 있습니다

사람이 매번 지적하고 있다면, 그중 일부는 Analyzer로 만들 수 있습니다.

특히 Analyzer에 적합한 것은 다음과 같은 규칙입니다.

좋고 나쁨을 명확히 판정할 수 있다
예외가 적다
수정 방침이 정해져 있다
팀 전체가 지키고 싶다
리뷰에서 자주 나온다
빌드에서 멈춰도 괜찮다

반대로 Analyzer에 적합하지 않은 것도 있습니다.

맥락에 의존해서 판단이 어렵다
설계 판단이 필요하다
예외가 너무 많다
사람에 따라 의견이 나뉜다
경고가 너무 많아서 아무도 보지 않게 된다

Analyzer는 강력한 도구입니다. 강력한 도구이기 때문에, 지나치게 많이 넣으면 개발 경험을 나쁘게 만듭니다. 처음에는 소수의 중요한 규칙부터 시작하는 것이 좋습니다.

14. .NET SDK에 포함된 Analyzer부터 시작하기

자체 Analyzer를 작성하기 전에, 먼저 .NET SDK에 포함된 Analyzer를 확인하는 것이 현실적입니다.

.NET 5 이후의 프로젝트에서는 .NET의 코드 분석이 기본적으로 활성화되어 있습니다.

자주 보는 진단 ID에는 다음 두 계통이 있습니다.

CAxxxx : 코드 품질, 신뢰성, 성능, 보안 등
IDExxxx: 코드 스타일, IDE 지원 등

Analyzer의 심각도는 .editorconfig로 조정할 수 있습니다.

# 예: 사용하지 않는 using을 경고로 만든다
dotnet_diagnostic.IDE0005.severity = warning

# 예: CA2000을 오류로 만든다
dotnet_diagnostic.CA2000.severity = error

프로젝트 파일에서 활성화나 엄격화를 수행하는 경우도 있습니다.

<PropertyGroup>
  <EnableNETAnalyzers>true</EnableNETAnalyzers>
  <AnalysisLevel>latest</AnalysisLevel>
  <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>

기존 프로젝트에 도입하면 처음에는 경고가 대량으로 나오는 경우가 있습니다. 이런 경우에는 갑자기 전부를 오류로 만들지 말고 단계적으로 진행하는 것이 좋습니다.

먼저 전체 경고 수를 파악한다
신규 코드에서 늘리지 않는 방침으로 한다
중요한 규칙만 warning으로 만든다
정말로 지키고 싶은 규칙만 error로 만든다
기존 위반은 계획적으로 줄인다

자체 Analyzer는 이 토대 위에서 ‘자사 고유의 규칙’을 보완하는 것으로 생각하면 좋습니다.

15. Analyzer의 최소 이미지

Analyzer는 특정 구문이나 심볼을 찾아 Diagnostic을 보고합니다.

예를 들어 DateTime.Now의 사용을 경고하는 Analyzer를 생각해 봅니다.

실제 제품 코드에서는 타입 해석이나 예외 케이스를 꼼꼼히 다뤄야 하지만, 최소한의 이미지는 다음과 같습니다.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoDateTimeNowAnalyzer : DiagnosticAnalyzer
{
    private static readonly DiagnosticDescriptor Rule = new(
        id: "CMP001",
        title: "DateTime.Now를 직접 사용하지 않기",
        messageFormat: "DateTime.Now가 아니라 용도에 맞게 DateTimeOffset.UtcNow 등을 검토해 주세요",
        category: "Usage",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterSyntaxNodeAction(
            AnalyzeMemberAccess,
            SyntaxKind.SimpleMemberAccessExpression);
    }

    private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
    {
        var memberAccess = (MemberAccessExpressionSyntax)context.Node;

        if (memberAccess.Name.Identifier.Text != "Now")
        {
            return;
        }

        var symbol = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol;
        if (symbol is not IPropertySymbol propertySymbol)
        {
            return;
        }

        if (propertySymbol.Name == "Now" &&
            propertySymbol.ContainingType.ToDisplayString() == "System.DateTime")
        {
            var diagnostic = Diagnostic.Create(Rule, memberAccess.GetLocation());
            context.ReportDiagnostic(diagnostic);
        }
    }
}

이 예에서 중요한 것은 단순히 문자열로 DateTime.Now를 찾고 있지 않다는 점입니다.

SemanticModel을 사용해서 실제로 System.DateTime.Now를 가리키는지를 확인하고 있습니다.

그래서 다음과 같은 다른 것을 오검출하기 어려워집니다.

MyCompany.DateTime.Now

Analyzer에서는 이렇게 구문으로 후보를 좁히고, 의미 분석으로 정말 대상인지 확인하는 흐름이 자주 사용됩니다.

Syntax로 빠르게 후보를 찾는다
SemanticModel로 정확하게 판정한다
Diagnostic으로 위치와 메시지를 보고한다

16. Code Fix는 ‘고치는 방법’까지 전달하는 방식

Analyzer는 문제를 찾고, Code Fix는 그 문제의 고치는 방법을 제시합니다.

예를 들어 DateTime.Now를 검출했을 때, 이런 수정안을 제시할 수 있습니다.

DateTimeOffset.UtcNow로 교체한다
IClock.Now 같은 추상화로 교체한다

다만 Code Fix는 신중하게 설계해야 합니다. DateTime.Now를 항상 DateTimeOffset.UtcNow로 바꾸면 된다고는 할 수 없습니다. 로컬 시각을 표시하고 싶은 경우와, 저장·비교용 시각을 다루고 싶은 경우에는 적절한 타입이나 시간대(time zone) 처리가 다릅니다.

그래서 Code Fix는 다음과 같은 조건을 만족할 때 적합합니다.

수정 후의 의미가 명확하다
부작용이 작다
기계적인 변환으로 안전하게 고칠 수 있다
사람이 확인하기 쉽다

예를 들어 이런 종류의 수정은 Code Fix와 궁합이 좋습니다.

오래된 API 이름을 새 API 이름으로 교체한다
부족한 using을 추가한다
명명 규칙에 맞춰 이름을 바꾼다
속성(attribute)을 추가한다
불필요한 인수를 삭제한다

한편, 설계 판단이 필요한 수정은 자동 수정보다 경고만 내는 쪽이 나은 경우가 있습니다.

17. Source Generator는 ‘컴파일 시점의 코드 생성’

Source Generator는 컴파일 시점에 실행되어, 생성한 C# 코드를 같은 컴파일에 추가합니다.

이미지로 말하면 다음과 같은 흐름입니다.

사용자의 소스 코드를 읽는다
속성이나 타입 정의를 조사한다
필요한 C# 코드를 생성한다
생성한 코드를 컴파일 대상에 추가한다

간단한 Source Generator의 예입니다.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

[Generator]
public sealed class BuildInfoGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(static ctx =>
        {
            var source = """
namespace Generated;

public static class BuildInfo
{
    public static string Tool => "Roslyn Source Generator";
}
""";

            ctx.AddSource(
                "BuildInfo.g.cs",
                SourceText.From(source, Encoding.UTF8));
        });
    }
}

이 Generator를 참조한 프로젝트에서는, 소스 파일을 작성하지 않아도 이 타입을 사용할 수 있게 됩니다.

Console.WriteLine(Generated.BuildInfo.Tool);

Source Generator는 사용자의 기존 코드를 다시 쓰는 것이 아닙니다. 할 수 있는 일은 추가적인 소스 코드를 생성해서 컴파일에 참여시키는 것입니다.

따라서 다음과 같이 생각하면 이해하기 쉽습니다.

기존 코드를 변환하는 것이 아니다
기존 코드를 보고, 추가 코드를 만드는 것

기존 코드를 일괄로 다시 쓰고 싶은 경우에는, Source Generator가 아니라 Roslyn을 사용한 마이그레이션 도구나 Code Fix를 검토합니다.

18. Source Generator 참조 방법

개발 중에 Generator 프로젝트를 다른 프로젝트에서 참조하는 경우, 일반적인 라이브러리 참조와는 다르게 취급됩니다.

생성기는 실행 시에 참조하는 라이브러리가 아니라, 컴파일 시점에 Analyzer로 로드되는 것이기 때문입니다.

프로젝트 참조에서는 다음과 같이 지정합니다.

<ItemGroup>
  <ProjectReference Include="..\BuildInfoGenerator\BuildInfoGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

ReferenceOutputAssembly="false"로 지정함으로써, Generator의 DLL을 일반 참조 어셈블리로 취급하지 않도록 합니다.

NuGet 패키지로 배포하는 경우에도, Analyzer / Source Generator로 로드되도록 배치합니다.

Source Generator는 편리하지만, 일반적인 라이브러리와는 생명주기(lifecycle)가 다릅니다.

일반 라이브러리: 실행 시에 앱에서 사용된다
Source Generator: 컴파일 시점에 컴파일러가 사용한다

이 차이를 의식해 두는 것이 중요합니다.

19. Source Generator에 적합한 것

Source Generator는 무엇이든 생성하면 되는 것이 아닙니다.

적합한 것은 다음과 같은 코드입니다.

사람이 작성하면 지루하고 실수하기 쉽다
입력 정보에서 기계적으로 결정된다
생성 결과가 읽기 쉽다
실행 시 Reflection을 줄일 수 있다
AOT나 트리밍과의 호환성을 좋게 할 수 있다
타입 안전성을 높일 수 있다

예를 들어 보겠습니다.

JSON 직렬화용 메타데이터
DI 등록 코드
설정값 접근자
API 클라이언트
enum 변환 코드
SQL이나 CSV 정의로부터의 타입 생성
INotifyPropertyChanged의 보조 코드

다만 생성된 코드가 너무 복잡하면, 문제가 발생했을 때 추적하기 어려워집니다.

Source Generator를 사용할 때는 다음과 같은 점을 의식하면 좋습니다.

생성 코드를 확인할 수 있게 한다
생성 코드의 이름을 안정시킨다
생성 결과를 결정적(deterministic)으로 만든다
오류 시의 Diagnostic을 이해하기 쉽게 만든다
입력이 조금만 바뀌어도 대량의 차이(diff)가 나지 않도록 한다

생성 코드는 마법처럼 보여서는 안 됩니다. 나중에 유지보수할 사람이 읽을 수 있는 코드를 내는 것이 중요합니다.

20. Source Generator에 적합하지 않은 것

Source Generator에 적합하지 않은 처리도 있습니다.

실행 시의 상태에 의존하는 처리
네트워크 접근이 필요한 처리
외부 서비스의 현재 값에 의존하는 처리
매번 결과가 바뀌는 처리
기존 소스의 재작성
거대한 전체 솔루션 분석

Generator는 컴파일 시점에 동작하기 때문에, 느린 Generator는 빌드 시간이나 IDE 경험을 악화시킵니다.

또한 환경에 의존하는 Generator는 다음과 같은 문제를 일으킵니다.

개발자의 PC에서는 통과하지만 CI에서는 실패한다
CI에서는 통과하지만 다른 OS에서는 실패한다
캐시 상태에 따라 결과가 바뀐다
네트워크 장애로 빌드가 실패한다

Source Generator는 가능한 한 순수한 처리에 가깝게 만드는 것이 좋습니다.

입력: 소스 코드, AdditionalFiles, AnalyzerConfigOptions
출력: 생성된 C# 코드, Diagnostic

이 관계가 명확할수록 안정적인 Generator가 됩니다.

21. Roslyn을 사용한 코드 조사 도구

Roslyn의 용도는 Analyzer나 Source Generator뿐만이 아닙니다. 직접 만든 콘솔 도구에서 Roslyn을 사용하는 방법도 실무에서는 유효합니다.

예를 들어 다음과 같은 요구 사항이 있습니다.

오래된 API의 사용 위치를 목록화하고 싶다
프로젝트별 public class 수를 세고 싶다
특정 속성이 붙은 클래스를 CSV로 만들고 싶다
거대한 솔루션에서 의존하고 있는 네임스페이스를 조사하고 싶다
.NET Framework 마이그레이션 전에 Windows 종속 API를 모두 찾아내고 싶다

이런 경우에는 Analyzer로 만들기보다, 일회성 또는 정기 실행하는 조사 도구로 만드는 쪽이 다루기 쉬운 경우가 있습니다.

예로서, 솔루션 안의 public class를 나열하는 간단한 이미지입니다.

using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;

MSBuildLocator.RegisterDefaults();

using var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(args[0]);

foreach (var project in solution.Projects)
{
    foreach (var document in project.Documents)
    {
        var root = await document.GetSyntaxRootAsync();
        if (root is null)
        {
            continue;
        }

        var classes = root.DescendantNodes()
            .OfType<ClassDeclarationSyntax>()
            .Where(c => c.Modifiers.Any(m => m.Text == "public"));

        foreach (var cls in classes)
        {
            Console.WriteLine($"{project.Name},{document.FilePath},{cls.Identifier.Text}");
        }
    }
}

이 예는 구문만 보고 있습니다. 만약 ‘특정 기반 클래스를 상속하는 public class’를 조사하고 싶다면, SemanticModel을 사용해 타입의 상속 관계를 살펴볼 필요가 있습니다.

이름이나 형태만으로 충분하다면 Syntax
타입이나 참조 대상까지 필요하다면 SemanticModel
프로젝트 전체를 다룬다면 Workspace

이 구분이 기본입니다.

22. 정규 표현식과의 차이

정규 표현식은 편리하지만, C# 코드의 의미를 다루는 데는 적합하지 않습니다.

예를 들어 다음 코드를 생각해 봅니다.

// Console.WriteLine("debug");

정규 표현식으로 Console.WriteLine을 찾으면, 주석 안의 문자열도 함께 걸릴 수 있습니다.

이런 문자열 리터럴도 있습니다.

var text = "Console.WriteLine";

또는 줄바꿈을 사이에 둘 수도 있습니다.

Console
    .WriteLine("Hello");

게다가 별칭(alias)을 사용하는 경우도 있습니다.

using C = System.Console;

C.WriteLine("Hello");

정규 표현식으로 이것들을 정확히 다루기는 어렵습니다.

Roslyn을 사용하면 주석, 문자열 리터럴, 구문상의 메서드 호출, 실제로 해석된 메서드를 구분해서 다룰 수 있습니다.

물론 단순한 조사라면 grep이나 ripgrep으로 충분한 경우도 있습니다. 하지만 그 결과를 바탕으로 설계 판단이나 자동 수정을 한다면, Roslyn을 사용하는 쪽이 안전합니다.

대략적으로 찾아보는 정도라면 문자열 검색
C#으로서 정확히 판정하고 싶다면 Roslyn

23. 기존 자산 조사에 Roslyn 활용하기

.NET Framework에서 현행 .NET으로 마이그레이션할 때, 가장 먼저 필요한 것은 ‘현황 파악’입니다. 이때 Roslyn이 도움이 됩니다.

예를 들어 다음과 같은 조사입니다.

System.Web 의존 목록
App.config / Web.config를 전제로 한 코드 목록
Windows Forms / WPF 고유 API의 사용 위치
Remoting / BinaryFormatter의 사용 위치
COM 참조 유무
P/Invoke 목록
비동기화되지 않은 I/O 처리
오래된 암호화 API의 사용 위치

단순한 문자열 검색으로도 후보를 낼 수 있습니다. 하지만 Roslyn을 사용하면, 타입이나 메서드로 해석된 결과에 기반해 목록화할 수 있습니다.

예를 들어 BinaryFormatter라는 문자열만 찾으면, 주석이나 문서도 함께 걸립니다.

Roslyn으로 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter 타입의 사용을 조사하면, 더 정확한 후보를 낼 수 있습니다.

마이그레이션 작업에서는 처음부터 완벽한 Analyzer를 만들 필요가 없습니다. 우선은 조사용 콘솔 도구로 이런 CSV를 낼 수 있는 것만으로도 가치가 있습니다.

Project,File,Line,Symbol,Kind
Legacy.Web,Controllers/HomeController.cs,42,System.Web.HttpContext.Current,Property
Legacy.Core,Serialization/OldStore.cs,18,System.Runtime.Serialization.Formatters.Binary.BinaryFormatter,Type

이런 목록이 있으면 마이그레이션 계획을 세우기 쉬워집니다.

24. 라이브러리 개발자에게 있어서의 Roslyn

Roslyn은 애플리케이션 개발자뿐 아니라 라이브러리 개발자에게도 유용합니다. 라이브러리에는 올바른 사용법이 있습니다.

예를 들어 다음과 같은 규칙입니다.

초기화 메서드를 먼저 호출해야 한다
특정 속성을 붙여야 한다
Dispose를 호출해야 한다
특정 옵션 지정이 위험하다
비권장 API를 신규 코드에서 사용하지 않았으면 한다

이것들을 문서로만 전달하면, 사용자가 놓치는 경우가 있습니다.

Analyzer를 라이브러리의 NuGet 패키지에 동봉하면, 사용자의 코드상에서 경고를 낼 수 있습니다.

예를 들어 자사 라이브러리 Company.Messaging이 있다고 하면, 이런 오용을 검출할 수 있습니다.

var client = new MessageClient();
client.Send(message); // Configure를 호출하기 전에 Send하고 있음

Analyzer는 이런 경고를 낼 수 있습니다.

CMP1001: MessageClient.Send를 호출하기 전에 Configure를 호출해 주세요

게다가 Code Fix로 수정 후보나 샘플 코드를 제시할 수 있습니다. 이는 라이브러리의 사용 경험을 개선합니다.

문서에 적혀 있는 내용을, 사용자의 에디터상에서 전달한다

이런 발상이 Roslyn의 큰 가치입니다.

25. Analyzer를 NuGet으로 배포할 때의 사고방식

Analyzer는 NuGet 패키지로 배포할 수 있습니다. 다만 일반적인 실행 시 라이브러리와는 구분해서 생각할 필요가 있습니다. Analyzer는 애플리케이션 실행 시에 필요한 것이 아니라, 빌드 시점이나 IDE상에서 사용되는 것이기 때문입니다.

그래서 패키지 설계에서는 다음과 같은 점을 고려합니다.

실행 시 라이브러리와 Analyzer를 같은 패키지에 포함할 것인가
Analyzer를 별도 패키지로 만들 것인가
기본값으로 경고를 낼 것인가
심각도를 어떻게 할 것인가
.editorconfig로 제어할 수 있게 할 것인가
기존 사용자에게 갑자기 대량의 경고를 내지 않을 것인가

자사 내부에서 사용한다면, 어느 정도 강한 규칙이라도 받아들이기 쉬울 수 있습니다.

공개 라이브러리로 배포한다면, 사용자의 빌드를 갑자기 깨뜨리지 않는 배려가 필요합니다.

처음에는 InfoWarning으로 시작하고, 필요에 따라 사용자 쪽에서 Error로 올릴 수 있게 하는 쪽이 다루기 쉬운 경우가 많습니다.

26. Roslyn과 IDE 기능

Visual Studio나 기타 .NET 개발 환경에서는, Roslyn의 사고방식이 IDE 기능에도 깊이 관여하고 있습니다.

예를 들어 다음과 같은 기능입니다.

IntelliSense
Go to Definition
Find All References
Rename
Extract Method
Quick Actions
코드 스타일 경고
사용하지 않는 using의 검출

이것들은 단순한 문자열 검색으로는 실현할 수 없습니다. 예를 들어 Rename에서는 같은 이름의 다른 심볼을 잘못 변경해서는 안 됩니다.

class User
{
    public string Name { get; set; }
}

class Product
{
    public string Name { get; set; }
}

User.Name을 변경하고 싶을 때, Product.Name까지 바꾸어서는 안 됩니다. 이는 구문뿐만 아니라 심볼로서 구별할 필요가 있습니다.

Roslyn의 API는 이러한 IDE적인 기능을 직접 만든 도구에도 응용할 수 있는 토대가 됩니다.

27. 성능상의 주의점

Roslyn은 강력하지만, 무거운 처리를 작성하면 당연히 느려집니다. 특히 Analyzer와 Source Generator는 개발자가 입력하는 중이나 빌드 중에 동작할 가능성이 있습니다.

그래서 다음과 같은 점에 주의합니다.

불필요한 SemanticModel 취득을 피한다
Syntax로 후보를 좁힌 후에 의미 분석을 한다
파일 I/O를 피한다
네트워크 접근을 하지 않는다
무거운 Reflection을 피한다
취소 요청을 존중한다
병렬 실행을 의식한다
전체 솔루션 분석을 Analyzer에 끌어들이지 않는다

Analyzer에서는 Initialize에서 등록하는 대상을 가능한 한 좁힙니다.

나쁜 예입니다.

모든 SyntaxNode를 본 다음, 내부에서 대량의 if문으로 판정한다

좋은 방향입니다.

필요한 SyntaxKind만 등록한다
먼저 이름이나 형태로 가볍게 좁힌다
필요한 경우에만 SemanticModel로 확정한다

Analyzer는 사용자의 개발 환경에 상주할 가능성이 있습니다. 그래서 정확성뿐만 아니라 가벼움도 품질입니다.

28. Diagnostic 설계

Analyzer에서 내는 Diagnostic은 단순히 경고만 내면 되는 것이 아닙니다. 개발자가 보았을 때, 다음과 같은 것을 알 수 있어야 합니다.

무엇이 문제인가
왜 문제인가
어디를 고치면 되는가
어떻게 고치면 되는가
예외가 있는가

나쁜 메시지의 예입니다.

CMP001: 금지되어 있습니다

이것만으로는 무엇이 나쁜지 알 수 없습니다.

좋은 방향의 예입니다.

CMP001: DateTime.Now는 실행 환경의 로컬 시각에 의존합니다. 저장·비교용 시각에는 DateTimeOffset.UtcNow 또는 시각 제공자(time provider)를 사용해 주세요.

Diagnostic ID도 미리 설계해 두면 좋습니다.

CMP0001-CMP0999: 공통 규칙
CMP1000-CMP1999: 라이브러리 A의 규칙
CMP2000-CMP2999: 마이그레이션 지원 규칙

문서 페이지를 준비할 수 있는 경우에는, DiagnosticDescriptor에 HelpLinkUri를 설정하는 것도 유효합니다.

경고는 개발자에게 보내는 커뮤니케이션입니다. 메시지가 조잡하면 규칙 자체도 신뢰받지 못하게 됩니다.

29. 심각도 설계

Analyzer의 심각도는 신중하게 결정합니다. 대표적인 단계는 다음과 같습니다.

Hidden / Silent
Info
Suggestion
Warning
Error

실무에서는 갑자기 Error로 만들지 않는 것이 좋은 경우가 많습니다. 특히 기존 코드가 많은 경우, 처음부터 Error로 만들면 도입이 멈춥니다.

현실적으로는 다음과 같은 단계적 도입이 쉽습니다.

1. 먼저 Warning으로 도입한다
2. CI에서 경고 수를 가시화한다
3. 신규 위반을 늘리지 않는다
4. 중요한 규칙만 Error로 만든다
5. 기존 위반을 줄이는 계획을 세운다

Analyzer의 목적은 개발자를 곤란하게 만드는 것이 아니라, 코드베이스의 품질을 무리 없이 높이는 것입니다.

30. Source Generator 디버깅

Source Generator는 일반 애플리케이션과는 실행되는 위치가 다르기 때문에, 디버깅에 약간의 특성이 있습니다.

기본적으로는 다음과 같은 방법으로 조사합니다.

생성된 소스를 본다
Diagnostic을 낸다
테스트를 작성한다
필요하다면 디버거를 연결한다

SDK 스타일 프로젝트에서는, 생성 파일을 출력하는 설정을 사용하면 확인하기 쉬워집니다.

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

이를 통해 생성된 .g.cs를 확인하기 쉬워집니다.

$(BaseIntermediateOutputPath)는 보통 obj/ 하위를 가리킵니다.

Generated처럼 프로젝트 바로 아래를 지정하면, SDK 스타일 프로젝트에서는 기본적으로 **/*.cs가 컴파일 대상에 포함되기 때문에, 다음 빌드에서 이미 생성된 .g.cs가 일반 소스로 다시 포함되어, 타입이나 멤버가 중복되는 오류가 발생할 수 있습니다.

어쩔 수 없이 프로젝트 바로 아래에 출력하는 경우에는, <Compile Remove="Generated/**/*.cs" />처럼 명시적으로 컴파일 대상에서 제외합니다.

Generator의 테스트에서는 입력 코드와 생성 결과를 비교하는 형태가 자주 사용됩니다.

입력 소스를 준비한다
Generator를 실행한다
생성된 소스를 확인한다
예상하는 Diagnostic을 확인한다

Source Generator는 수작업으로 동작 확인만 하면 쉽게 깨집니다. 생성 로직이 복잡해질수록 테스트가 중요합니다.

31. Roslyn을 사용한 테스트

Analyzer나 Source Generator는 테스트를 작성하며 키워 나가야 합니다. 특히 Analyzer는 오검출과 미탐지 양쪽 모두가 문제가 됩니다.

테스트에서는 다음과 같은 패턴을 준비합니다.

검출해야 할 코드
검출해서는 안 되는 코드
using alias를 사용한 코드
완전한 정규화 이름(fully qualified name)을 사용한 코드
비슷한 이름의 다른 타입을 사용한 코드
generated code로 취급되는 코드
nullable 활성화 시의 코드

예를 들어 System.DateTime.Now를 금지하는 Analyzer라면, 다음과 같은 케이스를 확인합니다.

// 검출해야 함
var x = System.DateTime.Now;
// using이 있는 경우도 검출해야 함
using System;
var x = DateTime.Now;
// 다른 타입이면 검출해서는 안 됨
namespace MyCompany;

public static class DateTime
{
    public static string Now => "now";
}

var x = DateTime.Now;

이 마지막 케이스는 문자열 검색에서는 틀리기 쉬운 예입니다. Roslyn Analyzer에서는 SemanticModel로 대상 심볼을 확인함으로써 이를 피할 수 있습니다.

32. .NET Framework 프로젝트에서도 사용할 수 있는가

Roslyn은 현행 .NET만을 위한 것이 아닙니다. 다만 ‘어떻게 사용하는가’에 따라 주의점이 달라집니다.

조사 도구로 사용하는 경우

.NET 8이나 .NET 10의 콘솔 앱으로 Roslyn 도구를 만들고, .NET Framework 솔루션을 로드해서 분석하는 것은 현실적인 선택입니다.

이 경우, 도구 자체는 현행 .NET에서 실행하고, 분석 대상은 .NET Framework의 코드로 할 수 있습니다.

다만 MSBuildWorkspace로 솔루션을 로드하는 경우에는, 대상 프로젝트를 빌드할 수 있는 MSBuild, SDK, 참조 어셈블리, NuGet 복원 환경이 필요합니다.

즉, Roslyn만으로 무엇이든 읽을 수 있는 것은 아니며, 실제 프로젝트 구성을 해석하려면 빌드 환경이 필요합니다.

Analyzer로 사용하는 경우

Analyzer는 컴파일러나 IDE에 로드되어 동작합니다.

대상 프로젝트가 .NET Framework라도, 컴파일러가 Analyzer를 로드할 수 있는 환경이라면 이용할 수 있습니다.

다만 오래된 csproj, 오래된 Visual Studio, 오래된 MSBuild, packages.config를 전제로 한 구성에서는, 도입이나 운영이 현행 SDK 스타일만큼 단순하지 않을 수 있습니다.

기존 .NET Framework 프로젝트에 도입하는 경우에는, 먼저 다음과 같은 점을 확인하면 좋습니다.

Visual Studio / MSBuild의 버전
PackageReference를 사용할 수 있는가
CI에서 같은 Analyzer가 동작하는가
경고가 빌드 로그에 나오는가
.editorconfig가 적용되는가

Source Generator로 사용하는 경우

Source Generator는 컴파일 시점에 컴파일러가 로드하는 방식입니다.

그래서 대상 프로젝트의 실행 시 프레임워크보다, 빌드에 사용하는 컴파일러나 SDK의 지원 여부가 중요합니다.

현행 .NET의 SDK 스타일 프로젝트에서는 다루기 쉬운 반면, 오래된 .NET Framework 프로젝트에서는 프로젝트 형식이나 빌드 환경에 따라 주의가 필요합니다.

.NET Framework의 기존 자산에 대해서는, 처음부터 Source Generator를 도입하기보다, 먼저 Roslyn 기반의 조사 도구나 Analyzer부터 시작하는 쪽이 안전한 경우가 많습니다.

33. 버전 선정 시 주의점

Roslyn 관련 NuGet 패키지에는 Microsoft.CodeAnalysis.*가 포함됩니다.

대표적으로는 다음과 같습니다.

Microsoft.CodeAnalysis.CSharp
Microsoft.CodeAnalysis.CSharp.Workspaces
Microsoft.CodeAnalysis.Workspaces.MSBuild
Microsoft.CodeAnalysis.Analyzers
Microsoft.CodeAnalysis.CSharp.CodeFix.Testing
Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing

여기서 주의해야 할 점은, Analyzer나 Source Generator가 사용자 쪽의 컴파일러에 로드된다는 점입니다.

즉, 개발자의 로컬 환경이나 CI의 SDK / Visual Studio가 오래된 경우, 지나치게 새로운 Roslyn API를 사용한 Analyzer / Generator가 동작하지 않을 수 있습니다.

사내 전용으로 빌드 환경을 통일할 수 있다면, 비교적 새로운 API를 사용하기 쉽습니다.

반면, 외부 배포용 라이브러리에서는 넓은 범위의 사용자 환경을 고려해서, 의존하는 Microsoft.CodeAnalysis의 버전을 신중하게 선택할 필요가 있습니다.

방침으로는 다음과 같이 생각하면 좋습니다.

사내 전용: CI와 개발 환경을 맞춘 상태에서 비교적 새로운 API를 사용한다
외부 배포: 사용자의 SDK/VS 범위를 고려해 보수적으로 선택한다
Generator: 가능하다면 Incremental Generator로 설계한다
Analyzer: IDE 경험을 해치지 않는 가벼움을 우선한다

Roslyn은 컴파일러에 가까운 영역이기 때문에, 버전 차이의 영향을 받기 쉽습니다.

34. Roslyn으로 무엇이든 해결하려 하지 않기

Roslyn은 강력하지만, 모든 문제를 푸는 도구는 아닙니다. 예를 들어 다음과 같은 문제는 Roslyn만으로는 풀 수 없습니다.

실행 시에 어느 분기를 지나는가
운영 데이터에서 어떤 값이 오는가
리플렉션으로 동적으로 호출되는 메서드
DI 컨테이너의 실행 시 등록 결과
설정 파일에 따라 달라지는 처리
외부 서비스에서 반환되는 값

Roslyn은 주로 소스 코드와 컴파일 정보를 다루는 도구입니다. 실행 시의 동작을 알고 싶은 경우에는 테스트, 로그, 추적, 프로파일링, 덤프 분석 등 다른 수단이 필요합니다.

따라서 Roslyn의 역할은 다음과 같이 생각하면 좋습니다.

정적으로 알 수 있는 것을 높은 정밀도로 다룬다

동적으로만 알 수 있는 것까지 Roslyn으로 무리하게 풀려고 하면, 복잡하고 부정확한 구조가 됩니다.

35. 도입 순서

실무에서 Roslyn을 사용하기 시작한다면, 다음 순서를 추천합니다.

1. 기존 .NET Analyzer와 .editorconfig를 정비한다
2. Syntax Tree를 이용한 작은 조사 도구를 작성한다
3. SemanticModel을 이용해 타입 해석을 시도한다
4. MSBuildWorkspace로 솔루션을 읽는다
5. 팀 고유의 작은 Analyzer를 만든다
6. 필요하다면 Code Fix를 추가한다
7. 정형 코드가 많은 부분에 Source Generator를 검토한다

처음부터 Source Generator로 갈 필요는 없습니다. 많은 현장에서는, 먼저 Analyzer와 조사 도구 쪽이 효과를 내기 쉽습니다.

특히 기존 자산이 큰 경우에는, 다음과 같은 흐름이 현실적입니다.

조사 도구로 현황을 파악한다
자주 나오는 문제를 Analyzer화한다
안전하게 고칠 수 있는 것만 Code Fix화한다
반복해서 작성하는 정형 코드를 Generator화한다

Roslyn은 단계적으로 사용할 수 있는 도구입니다.

36. 작은 샘플: 메서드 호출 목록화하기

마지막으로, Roslyn의 사용법을 좀 더 실무에 가까운 형태로 살펴봅니다. 여기서는 솔루션 안의 메서드 호출을 목록화하는 이미지입니다.

using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;

MSBuildLocator.RegisterDefaults();

using var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(args[0]);

foreach (var project in solution.Projects)
{
    var compilation = await project.GetCompilationAsync();
    if (compilation is null)
    {
        continue;
    }

    foreach (var document in project.Documents)
    {
        var tree = await document.GetSyntaxTreeAsync();
        if (tree is null)
        {
            continue;
        }

        var root = await tree.GetRootAsync();
        var semanticModel = compilation.GetSemanticModel(tree);

        var invocations = root
            .DescendantNodes()
            .OfType<InvocationExpressionSyntax>();

        foreach (var invocation in invocations)
        {
            var symbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol;
            if (symbol is null)
            {
                continue;
            }

            var lineSpan = invocation.GetLocation().GetLineSpan();
            var line = lineSpan.StartLinePosition.Line + 1;

            Console.WriteLine(string.Join(",", new[]
            {
                project.Name,
                document.FilePath ?? document.Name,
                line.ToString(),
                symbol.ContainingType.ToDisplayString(),
                symbol.Name
            }));
        }
    }
}

이런 도구를 조금 확장하면, 다음과 같은 조사를 할 수 있습니다.

특정 메서드의 호출만 추출한다
비권장 API의 사용 위치를 낸다
프로젝트별 사용 빈도를 낸다
마이그레이션 대상 API의 목록을 만든다

소스 코드를 컴파일러의 시각으로 읽을 수 있으면, 기존 코드의 조사가 훨씬 편해집니다.

37. Roslyn으로 코드를 다시 쓸 때의 주의점

Roslyn에서는 구문 트리를 이용해 코드를 다시 쓸 수도 있습니다.

예를 들어 특정 메서드 이름을 바꾸거나, 속성을 추가하거나, using을 추가할 수 있습니다.

다만 코드 재작성은 신중하게 수행해야 합니다. 주의점은 다음과 같습니다.

의미가 바뀌지 않는지 확인한다
주석이나 공백을 깨뜨리지 않는다
차이(diff)가 지나치게 커지지 않게 한다
포맷을 통일한다
한 번에 너무 많은 변환을 하지 않는다
Git 차이를 리뷰하기 쉽게 만든다

Roslyn의 Syntax Tree는 Trivia를 유지하기 때문에, 주석이나 공백을 유지한 변환이 가능합니다. 하지만 노드를 대충 만들면, 생성된 코드의 정렬이 무너지는 경우가 있습니다.

재작성 도구를 만들 때는 다음 방침이 안전합니다.

먼저 검출만 수행한다
변환 전후의 차이를 확인한다
작은 변환부터 시작한다
변환 도구 자체에 테스트를 작성한다
CI에서는 검출 모드부터 시작한다

대규모 기계 변환에서는 Roslyn이 강력하지만, 마지막에는 사람의 리뷰가 필요합니다.

38. Roslyn과 AI 코딩 지원

최근에는 AI에 의한 코드 생성이나 리뷰 지원도 일반화되고 있지만, 그 안에서도 Roslyn의 가치는 떨어지지 않습니다. AI는 자연어나 주변 맥락을 다루는 데 능숙하고, Roslyn은 컴파일러로서 정확한 구문·의미 정보를 다루는 데 능숙합니다. 둘은 경쟁 관계라기보다 상호 보완 관계입니다.

예를 들어 다음과 같은 구분 사용을 생각할 수 있습니다.

Roslyn으로 정확하게 대상 위치를 추출한다
AI로 수정 방침이나 설명문을 생성한다
Roslyn으로 수정안이 컴파일 가능한지 확인한다
Analyzer로 재발을 방지한다

AI에게 ‘이 코드베이스의 오래된 API를 전부 고쳐줘’라고 부탁하는 것보다, Roslyn으로 대상 위치를 정확하게 추출하는 쪽이 안전한 경우가 있습니다.

그리고 수정 방침의 검토나 리뷰 보조에 AI를 사용하면 더 실무적입니다.

컴파일러가 아는 것은 컴파일러에게 맡긴다. 사람이나 AI는 그 위의 판단에 집중한다.

이 역할 분담이 중요합니다.

39. 실무 체크리스트

Roslyn을 사용하기 전에, 다음과 같은 점을 확인하면 좋습니다.

목적은 조사인가, 경고인가, 수정인가, 생성인가
구문만으로 충분한가, 의미 분석이 필요한가
단일 파일이면 되는가, 프로젝트 전체가 필요한가
IDE상에서 동작시켜야 하는가, 일회성 도구면 되는가
빌드 시간에 영향을 주어도 괜찮은가
CI에서 실행할 것인가
기존 코드에 경고가 대량으로 발생하지 않는가
Analyzer의 심각도를 어떻게 할 것인가
Code Fix는 안전하게 적용할 수 있는가
Source Generator의 생성 코드를 확인할 수 있는가
사용자의 SDK / Visual Studio 버전이 맞춰져 있는가

판단하기 어려우면, 다음과 같이 나누면 좋습니다.

조사하고 싶다              -> Roslyn을 사용한 콘솔 도구
항상 지키게 하고 싶다        -> Analyzer
고치는 방법이 정해져 있다    -> Code Fix
정형 코드를 만들고 싶다      -> Source Generator

이렇게 나누면, Roslyn의 사용처를 헷갈리기 어려워집니다.

40. 정리

Roslyn은 C#이나 Visual Basic의 컴파일러를, 개발자가 이용할 수 있는 API로 개방한 것입니다.

Roslyn을 사용하면 소스 코드를 단순한 문자열이 아니라, 다음과 같은 형태로 다룰 수 있습니다.

Syntax Tree로 구문을 읽는다
SemanticModel로 의미를 읽는다
Compilation으로 컴파일 전체를 다룬다
Workspace로 솔루션이나 프로젝트를 다룬다
Analyzer로 경고를 낸다
Code Fix로 수정안을 제시한다
Source Generator로 코드를 생성한다

실무에서는 다음과 같은 상황에서 특히 유용합니다.

기존 코드베이스 조사
.NET Framework에서 .NET으로의 마이그레이션 지원
팀 규약 자동 검사
라이브러리 사용자를 위한 안내
정형 코드 생성
IDE나 CI에서의 품질 보증

중요한 것은, Roslyn을 ‘어려운 컴파일러 기술’로 지나치게 부담스러워하지 않는 것입니다.

처음에는 파일 하나를 CSharpSyntaxTree.ParseText로 읽어, 메서드 이름을 나열하는 것만으로 충분합니다. 거기서부터 SemanticModel, Workspace, Analyzer, Source Generator로 넓혀 가면 됩니다.

Roslyn을 한마디로 말하면, 이렇게 됩니다.

C# 코드를 문자열이 아니라, 컴파일러가 이해한 구조로 다룰 수 있게 한다.

이런 관점을 가지면, 코드 리뷰, 마이그레이션, 조사, 생성의 자동화가 한층 쉬워집니다.

참고

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

Roslyn이란 무엇인가요?
Roslyn은 정식으로 .NET Compiler Platform이라 불리는, C#과 Visual Basic의 컴파일러 구현이며, 동시에 코드 분석 도구를 만들기 위한 API 모음입니다. 예전에는 블랙박스였던 컴파일러가 내부에서 만들어 내는 정보(이 식별자가 어떤 타입인지, 이 호출이 어떤 메서드인지 등)를 애플리케이션이나 도구에서 이용할 수 있게 해 줍니다. 이를 통해 C# 코드를 문자열이 아니라 구문으로 읽고, 겉모습이 아니라 의미로 읽어, 경고·수정안·생성 코드를 낼 수 있게 됩니다.
Roslyn을 사용하면 무엇을 할 수 있나요?
C# / VB의 구문 분석, 타입이나 메서드의 의미 분석, 프로젝트나 솔루션 전체의 분석, 자체 Analyzer·Code Fix·Source Generator 작성, 코드 생성·변환 등을 할 수 있습니다. 실무에 가깝게 말하면, 금지된 API를 사용하면 빌드 경고를 내거나, 오래된 API의 사용 위치를 목록화하거나, DTO의 매핑 코드를 컴파일 시점에 생성하거나, .NET Framework에서 .NET으로의 마이그레이션 조사를 돕는 식의 사용법입니다. 사용법은 크게 라이브러리로 사용하기·Analyzer·Code Fix·Source Generator의 4가지로 나눌 수 있습니다.
정규 표현식이나 grep을 이용한 코드 검색과 무엇이 다른가요?
정규 표현식으로는 주석 안의 문자열이나 문자열 리터럴, 줄바꿈을 사이에 둔 호출, using alias를 사용한 별칭 호출을 정확히 다루기가 어렵습니다. Roslyn을 사용하면 주석, 문자열 리터럴, 구문상의 메서드 호출, 실제로 해석된 메서드를 구분해서 다룰 수 있습니다. 대략적으로 찾아보는 정도라면 문자열 검색으로 충분한 경우도 있지만, 그 결과를 바탕으로 설계 판단이나 자동 수정을 한다면 컴파일러의 이름 해석 결과에 기반해 판정할 수 있는 Roslyn 쪽이 더 안전합니다.
Roslyn은 어디서부터 배우기 시작하는 것이 좋을까요?
처음부터 Source Generator로 갈 필요는 없습니다. 먼저 기존 .NET SDK에 포함된 Analyzer와 .editorconfig를 정비하고, 다음으로 파일 하나를 CSharpSyntaxTree.ParseText로 읽어 메서드 이름을 나열하는 작은 조사 도구를 작성한 뒤, 여기서부터 SemanticModel을 이용한 타입 해석, MSBuildWorkspace를 이용한 솔루션 로딩, 팀 고유의 소규모 Analyzer로 넓혀가는 순서를 추천합니다. 많은 현장에서는 Source Generator보다 Analyzer와 조사 도구가 먼저 효과를 내기 쉽습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기