1. 소개
솔직히 말하면: 대부분 사람들은 코드 생성을 계란 썰기 기계처럼 생각해 — 유용하긴 한데 보통 필수는 아니지. 그런데 요즘 .NET 프로젝트는 점점 복잡해지고, 코드의 반복적이거나 템플릿화된 작업을 자동화하는 건 단순한 "기능"이 아니라 품질과 생산성을 올리는 중요한 도구야.
Source Generators는 C# 9과 .NET 5부터 도입된 메커니즘이야. 컴파일 단계의 슈퍼히어로처럼 동작해서, 프로젝트의 일부로 컴파일되는 C# 코드를 생성할 수 있어. 실행 중에 이미 컴파일된 어셈블리를 조작하는 게 아니라, 컴파일 전에 새로운 소스 파일로 프로젝트를 확장하는 방식이지.
아래에 Source Generators의 전형적인 사용 사례들을 정리할게.
템플릿 코드 자동 생성
많은 프로젝트에서 비슷한 코드(생성자, ToString 메서드, 직렬화기, property change notification(INotifyPropertyChanged) 등)를 반복적으로 작성해야 해. Source Generators는 이런 코드를 자동으로 생성해서 개발자의 반복 작업과 오타 위험을 줄여줘.
예제: ToString 생성기
DTO 클래스(Data Transfer Object)를 만들어 데이터를 전달한다고 해보자. 각 클래스에 속성들을 나열한 의미 있는 ToString 구현을 만들고 싶을 때가 있지.
수동으로 복사하지 않고, [AutoToString] 어트리뷰트가 붙은 각 클래스에 대해 Source Generator가 ToString 구현을 생성하게 할 수 있어. 예를 들어 라이브러리 AutoToString가 그런 식으로 동작해.
// 어트리뷰트가 붙은 너의 클래스
[AutoToString]
public partial class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
// Source Generator가 생성할 것(단순화):
public partial class Person
{
public override string ToString() => $"Person: Name={Name}, Age={Age}";
}
장점: 코드는 읽기 쉽고 새로운 속성이 추가되면 항상 갱신돼.
직렬화와 역직렬화 단순화
Source Generators는 표준 라이브러리인 System.Text.Json 내부에서도 널리 쓰여서 빠른 직렬화/역직렬화 코드를 생성해줘. 예전에는 리플렉션을 많이 써서 성능 비용이 컸는데, 이제는 생성된 코드가 성능과 안전성을 모두 제공해.
개발자가 얻는 것? [JsonSerializable(typeof(MyType))]를 지정하면, 해당 타입을 위한 고성능 직렬화 코드를 생성해줘.
설정, 매퍼, DI 컨테이너용 코드 생성
- 설정(Configs): 제너레이터가 JSON 파일을 기반으로 설정 클래스를 자동 생성해.
- 매퍼: 예를 들어 Mapster는 제너레이터를 통해 타입 간 매핑 코드를 생성해서 필드를 수동으로 복사할 필요를 없애.
- Dependency Injection: 일부 컨테이너(예: StrongInject)는 서비스 등록 코드를 생성하기 위해 제너레이터를 사용해.
외부 인프라와 통합
어떤 제너레이터는 API 설명, GraphQL 스키마, Thrift 프로토콜 등 외부 리소스를 분석해서 작업에 맞는 C# 클래스를 생성해줘. 이러면 계약이 바뀔 때 수동으로 코드를 업데이트할 필요가 없어.
컴파일 단계에서의 검사와 진단
Source Generators는 코드 검증에도 활용할 수 있어: "프로젝트에 메서드 X가 있는데 조건 Y가 안 맞으면 컴파일러 경고를 띄워!" 같은 식으로. 많은 린터와 analyzers가 비슷하게 동작하고, 제너레이터는 메시지를 추가하거나 스텁 코드를 생성하기도 해.
2. 자동 직렬화를 위한 커스텀 Source Generator
간단한 예제를 하나 보자: [AutoJson] 어트리뷰트가 붙은 클래스에 대해 JSON 직렬화 메서드를 생성하는 제너레이터를 만든다고 치자.
아래 코드는 설명용일 뿐이고, 실무에서는 System.Text.Json.SourceGeneration 같은 것을 쓰는 게 좋다.
// 수동으로 작성:
[AutoJson]
public partial class Book
{
public string Title { get; set; }
public int Year { get; set; }
}
// 생성기가 추가할 것:
public partial class Book
{
public string ToJson() => $"{{ \"Title\": \"{Title}\", \"Year\": {Year} }}";
}
Source Generator와 프로젝트의 상호작용 흐름
flowchart TD
A(네 소스 코드) -->|컴파일러가 호출| B(Source Generator)
B -->|추가된 코드| C(새로운 .cs 파일)
C --> D(프로젝트 컴파일)
- 먼저 컴파일러(Roslyn)가 소스 코드를 분석해.
- 그다음에 네가 구현한 제너레이터(ISourceGenerator 구현체)를 호출하지.
- 제너레이터는 새로운 .cs 파일을 추가해서 전체 컴파일 트리에 포함돼.
- 최종적으로 빌드에는 네 코드와 생성된 코드가 모두 포함돼.
특수한 작업: 더 만들 수 있는 것들
- 네이티브 라이브러리 바인딩 (C 코드나 WinAPI에 대한 래퍼).
- AOP: 자동 로깅, 애스펙트(예: Fody, PostSharp 스타일).
- API 설명을 위한 문서 자동 생성기.
- 외부 리소스 처리: SVG, SQL, Razor 등에서 strongly-typed 클래스를 생성해서 타입 안전하게 접근하게 함.
3. System.Reflection.Emit를 사용한 동적 코드 생성
Source Generators가 컴파일 전에 C# 코드를 생성한다면, System.Reflection.Emit은 실행 중에 마법을 부리는 도구야. 프로그램이 스스로 새로운 타입, 메서드, 심지어 어셈블리까지도 런타임에 생성할 수 있어.
좀 무서울 수도 있지? 조금 그래. 하지만 때로는 이게 유일한 방법일 때가 있어: 동적 프록시(AOP, 프로파일링, mocking), 런타임 데이터에 기반한 직렬화기 생성, 동적 ORM 등에서 필요해.
언제 Reflection.Emit가 필요한가
- 타입을 미리 알 수 없을 때 (사용자가 런타임에 구조를 정의하는 경우).
- 동적 프록시 (호출을 가로채는 래퍼).
- 고성능 런타임 직렬화 (예: protobuf-net 같은 경우).
- 플러그인/스크립트 엔진처럼 복잡한 로딩 시나리오를 다룰 때.
Reflection.Emit로 만들 수 있는 것
- AssemblyBuilder — 새로운 어셈블리 생성.
- ModuleBuilder — 어셈블리 안의 모듈.
- TypeBuilder — 새로운 타입 정의.
- MethodBuilder — IL 코드를 가진 메서드.
- PropertyBuilder, FieldBuilder, EventBuilder — 속성, 필드, 이벤트.
미니 예제: 런타임에 새 클래스 생성
using System;
using System.Reflection;
using System.Reflection.Emit;
public static class DynamicTypeGenerator
{
public static Type GenerateSimpleType(string typeName)
{
// 1. 어셈블리와 모듈 생성
var assemblyName = new AssemblyName("DynamicAssembly");
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
// 2. 새 클래스 생성
var typeBuilder = moduleBuilder.DefineType(
typeName,
TypeAttributes.Public | TypeAttributes.Class
);
// 3. public 문자열 속성 Title 추가
var field = typeBuilder.DefineField("_title", typeof(string), FieldAttributes.Private);
var prop = typeBuilder.DefineProperty("Title", PropertyAttributes.HasDefault, typeof(string), null);
var getMethod = typeBuilder.DefineMethod("get_Title", MethodAttributes.Public, typeof(string), Type.EmptyTypes);
var il = getMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Ldfld, field); // _title
il.Emit(OpCodes.Ret);
prop.SetGetMethod(getMethod);
var setMethod = typeBuilder.DefineMethod("set_Title", MethodAttributes.Public, null, new[] { typeof(string) });
il = setMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Stfld, field);
il.Emit(OpCodes.Ret);
prop.SetSetMethod(setMethod);
// 4. 완료! Type 생성
return typeBuilder.CreateTypeInfo();
}
}
이제 이 타입을 일반 C# 객체처럼 사용할 수 있어, 예를 들어 리플렉션으로:
var dynamicType = DynamicTypeGenerator.GenerateSimpleType("Book");
var obj = Activator.CreateInstance(dynamicType);
dynamicType.GetProperty("Title").SetValue(obj, "C# 인물들");
Console.WriteLine(dynamicType.GetProperty("Title").GetValue(obj)); // C# 인물들
이렇게 해서 ORM, 프록시, 직렬화기, 프로파일러, 테스트 프레임워크의 일부가 만들어져.
4. 유용한 팁
Source Generators vs Reflection.Emit: 누가 언제?
Source Generators는 컴파일 단계에서 동작해서 네 소스 코드를 더 스마트하게 만들어 주고, 결과는 빌드된 어셈블리에 포함돼. 런타임에 얻은 데이터 기반으로 코드를 생성하려면 쓸 수 없어.
Reflection.Emit는 런타임에서 동작해서 어셈블리, 타입, 메서드를 동적으로 만들 수 있어. 다만 그런 코드는 디버깅과 유지보수가 더 까다로워.
언제 무엇을 써야 할까?
비유를 하자면:
- Source Generators — 조립 전에 부품을 찍어내는 공장 같아.
- Reflection.Emit — 주행 중에 엔지니어가 차에 로켓 엔진을 용접해 붙이는 것 같아.
특징과 함정
- 생성된 코드는 디버깅하기 어려울 수 있어. 제너레이터는 종종 생성된 소스 파일을 디스크에 저장하는 옵션이 있으니 obj\Generated 폴더를 찾아봐.
- Reflection.Emit는 기본적으로 메모리에서 어셈블리를 생성하며 AppDomain에서 언로드되지 않을 수 있어. 임시 어셈블리에는 가능한 경우 AssemblyBuilderAccess.RunAndCollect를 사용해.
- 간단한 문제에 Reflection.Emit를 과도하게 쓰지 마 — 때로는 소스 제너레이터나 단순 코드 템플릿이 더 쉽고 안정적이야.
- 제너레이터를 쓰려면 Roslyn에 대한 이해가 필요하고, Reflection.Emit는 IL에 대한 이해가 필요해.
Source Generators와 Reflection.Emit
| 기준 | Source Generators | Reflection.Emit |
|---|---|---|
| 사용 시점 | 컴파일 단계 | 런타임 |
| 결과 | C# 소스, 빌드의 일부 | IL 코드, 새로운 타입/어셈블리 |
| 일반적인 시나리오 | 템플릿 코드 자동 생성, DI, 매핑, 직렬화 | 프록시, 동적 ORM, 특수 런타임 파이프라인 |
| 사용 난이도 | 중간, Roslyn 지식 필요 | 높음, IL 지식 필요 |
| IDE 및 디버깅 지원 | 좋음 (생성된 소스 확인 가능) | 어려움 |
| 성능 | 매우 높음 | 높을 수 있음 |
5. 실전 시나리오
1. strongly-typed API 생성
조직에서 OpenAPI 스펙을 제공한다고 해보자. 제너레이터가 스펙을 분석해서 컨트롤러 클래스, DTO, REST API용 클라이언트 코드를 생성하면 타입 안전하고 IntelliSense도 지원돼.
코드(의사코드):
// Spec: GET /users -> returns User[]
// Source Generator가 생성할 것:
public class ApiClient
{
public Task<User[]> GetUsersAsync() { ... }
}
2. 자동 등록/DI 컨테이너 (컴파일 타임 IoC)
제너레이터가 의존성 등록 빌더를 생성해서 객체 그래프를 구성해줘. 매번 services.AddSingleton<IMyService, MyService>()을 수동으로 작성할 필요가 없어.
코드(의사코드):
[Injectable]
public class MyService : IMyService { ... }
// Source Generator가 생성할 것:
partial class DIContainer
{
public void RegisterServices()
{
AddSingleton<IMyService, MyService>();
}
}
3. 동적 프록시 — Reflection.Emit 예제
예를 들어 Castle DynamicProxy 같은 라이브러리는 호출을 가로채는 프록시 타입을 생성해서 AOP, 로깅, 트레이싱, mocking의 기반을 만들어.
코드(단순화):
public interface IBookService { string GetBook(); }
public class BookService : IBookService { public string GetBook() => "C#"; }
var proxy = ProxyGenerator.CreateProxy<IBookService>(new BookService(), interceptor);
proxy.GetBook(); // 호출이 가로채졌고, 로그를 남기거나 결과를 바꿀 수 있음
4. 리플렉션 없이 빠른 직렬화
런타임에 타입 정보를 리플렉션으로 구성하는 대신(느림), Source Generators가 미리 직렬화/역직렬화 코드를 생성하면 최대한 빠르고 오버헤드가 적어.
GO TO FULL VERSION