Entity ID Generator
도메인 주도 설계에서 Guid orderId와 Guid userId를 혼동하는 실수는 컴파일러가 잡아주지 않습니다. 강타입 Entity Id는 이 문제를 타입 시스템으로 해결하지만, 매번 동일한 보일러플레이트를 수작업으로 작성해야 합니다. 이 절에서는 Part 2에서 배운 소스 생성기 패턴을 활용하여, [EntityId] 속성 하나로 Ulid 기반 강타입 Id를 자동 생성하는 생성기를 구현합니다.
학습 목표
섹션 제목: “학습 목표”핵심 학습 목표
섹션 제목: “핵심 학습 목표”- 도메인 주도 설계에서 강타입 Entity Id의 필요성 이해
- 원시 타입 Id가 초래하는 런타임 버그와 타입 안전성 확보 전략
- Ulid 기반 Entity Id 생성기 구현
- 마커 속성, 메타데이터 추출, 코드 생성까지의 전체 파이프라인
- partial record struct 처리 방법 습득
- 구문 노드 필터링과 심볼 분석에서의 partial 키워드 검증
왜 강타입 Entity Id인가?
섹션 제목: “왜 강타입 Entity Id인가?”원시 타입의 문제
섹션 제목: “원시 타입의 문제”// ❌ 원시 타입 사용 - 컴파일 타임 타입 안전성 없음public class OrderService{ public Order GetOrder(Guid orderId) { /* ... */ } public User GetUser(Guid userId) { /* ... */ }}
// 실수로 userId를 orderId 자리에 전달 → 컴파일 성공, 런타임 버그var user = orderService.GetUser(orderId); // 컴파일 OK, 버그!강타입 Id의 장점
섹션 제목: “강타입 Id의 장점”// ✅ 강타입 Entity Id - 컴파일 타임 타입 안전성public class OrderService{ public Order GetOrder(OrderId orderId) { /* ... */ } public User GetUser(UserId userId) { /* ... */ }}
// 컴파일 에러!var user = orderService.GetUser(orderId); // CS1503: Cannot convert 'OrderId' to 'UserId'강타입 Id가 왜 필요한지 확인했으니, 이제 소스 생성기가 만들어야 할 코드의 모습을 정의합니다.
목표: 자동 생성할 코드
섹션 제목: “목표: 자동 생성할 코드”using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[EntityId]public readonly partial record struct ProductId;생성 결과
섹션 제목: “생성 결과”// <auto-generated/>#nullable enable
using System;using System.Diagnostics;using Cysharp.Serialization;
namespace MyApp.Domain;
[DebuggerDisplay("{ToString()}")]public readonly partial record struct ProductId : IEntityId<ProductId>, IComparable<ProductId>{ public Ulid Value { get; }
private ProductId(Ulid value) => Value = value;
/// <summary>새로운 ProductId를 생성합니다.</summary> public static ProductId New() => new(Ulid.NewUlid());
/// <summary>기존 Ulid 값으로 ProductId를 생성합니다.</summary> public static ProductId Create(Ulid value) => new(value);
/// <summary>빈 ProductId입니다.</summary> public static ProductId Empty => new(Ulid.Empty);
public int CompareTo(ProductId other) => Value.CompareTo(other.Value);
public static bool operator >(ProductId left, ProductId right) => left.CompareTo(right) > 0;
public static bool operator <(ProductId left, ProductId right) => left.CompareTo(right) < 0;
public static bool operator >=(ProductId left, ProductId right) => left.CompareTo(right) >= 0;
public static bool operator <=(ProductId left, ProductId right) => left.CompareTo(right) <= 0;
public override string ToString() => Value.ToString();}Entity Id의 내부 타입으로 Guid 대신 Ulid를 선택한 이유를 살펴봅니다.
Ulid의 장점
섹션 제목: “Ulid의 장점”Guid vs Ulid 비교
섹션 제목: “Guid vs Ulid 비교”| 특성 | Guid | Ulid |
|---|---|---|
| 크기 | 16 bytes | 16 bytes |
| 생성 속도 | 73 ns | 65 ns |
| 정렬 가능 | ❌ 무작위 | ✅ 타임스탬프 기반 |
| 문자열 길이 | 36자 | 26자 |
| DB 인덱스 | 비효율적 | 효율적 (순차 삽입) |
Ulid 구조
섹션 제목: “Ulid 구조” 01AN4Z07BY79KA1307SR9X4MV3|----------|---------------| 타임스탬프 무작위 (48 bits) (80 bits) 10자 16자
- 타임스탬프: 밀리초 단위, 시간순 정렬 가능- 무작위: 충돌 방지용 랜덤 데이터NuGet 패키지
섹션 제목: “NuGet 패키지”<PackageReference Include="Ulid" Version="1.3.4" />목표 코드와 Ulid 기반 설계가 확정되었으니, 이전 절에서 정리한 7단계 워크플로우에 따라 생성기를 구현합니다.
1. 프로젝트 구조
섹션 제목: “1. 프로젝트 구조”MyCompany.SourceGenerator/├── EntityIdGenerator.cs # 메인 생성기├── EntityIdAttribute.cs # 속성 소스 코드 상수├── EntityIdInfo.cs # 메타데이터 record└── MyCompany.SourceGenerator.csproj2. 메타데이터 클래스
섹션 제목: “2. 메타데이터 클래스”namespace MyCompany.SourceGenerator;
/// <summary>/// Entity Id 생성에 필요한 메타데이터/// </summary>public sealed record EntityIdInfo( string TypeName, string Namespace, bool IsReadOnly);3. 마커 속성 정의
섹션 제목: “3. 마커 속성 정의”namespace MyCompany.SourceGenerator;
internal static class EntityIdAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Entity Id로 생성할 타입에 적용합니다. /// record struct에만 적용 가능합니다. /// </summary> /// <example> /// <code> /// [EntityId] /// public readonly partial record struct ProductId; /// </code> /// </example> [global::System.AttributeUsage( global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class EntityIdAttribute : global::System.Attribute; """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.EntityIdAttribute";}4. IEntityId 인터페이스 정의
섹션 제목: “4. IEntityId 인터페이스 정의”namespace MyCompany.SourceGenerator;
internal static class IEntityIdInterface{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// 모든 Entity Id가 구현해야 하는 인터페이스 /// </summary> /// <typeparam name="TSelf">Entity Id 타입 자신</typeparam> public interface IEntityId<TSelf> where TSelf : struct, IEntityId<TSelf> { /// <summary>내부 Ulid 값</summary> Ulid Value { get; }
/// <summary>새로운 Id 생성</summary> static abstract TSelf New();
/// <summary>기존 Ulid로부터 생성</summary> static abstract TSelf Create(Ulid value);
/// <summary>빈 Id</summary> static abstract TSelf Empty { get; } } """;}5. 메인 생성기
섹션 제목: “5. 메인 생성기”using System.Text;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp.Syntax;using Microsoft.CodeAnalysis.Text;
namespace MyCompany.SourceGenerator;
[Generator(LanguageNames.CSharp)]public sealed class EntityIdGenerator : IIncrementalGenerator{ public void Initialize(IncrementalGeneratorInitializationContext context) { // 1단계: 고정 코드 생성 (속성, 인터페이스) context.RegisterPostInitializationOutput(ctx => { ctx.AddSource( hintName: "EntityIdAttribute.g.cs", sourceText: SourceText.From(EntityIdAttribute.Source, Encoding.UTF8));
ctx.AddSource( hintName: "IEntityId.g.cs", sourceText: SourceText.From(IEntityIdInterface.Source, Encoding.UTF8)); });
// 2단계: [EntityId] 속성이 붙은 타입 수집 var provider = RegisterSourceProvider(context);
// 3단계: 코드 생성 context.RegisterSourceOutput(provider, Execute); }
private static IncrementalValuesProvider<EntityIdInfo> RegisterSourceProvider( IncrementalGeneratorInitializationContext context) { return context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: EntityIdAttribute.FullyQualifiedName, predicate: IsRecordStruct, transform: MapToEntityIdInfo) .Where(static x => x is not null)!; }
private static bool IsRecordStruct(SyntaxNode node, CancellationToken _) { // record struct만 대상 return node is RecordDeclarationSyntax record && record.ClassOrStructKeyword.IsKind( Microsoft.CodeAnalysis.CSharp.SyntaxKind.StructKeyword); }
private static EntityIdInfo? MapToEntityIdInfo( GeneratorAttributeSyntaxContext context, CancellationToken _) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
// partial 키워드 확인 if (!typeSymbol.DeclaringSyntaxReferences .Any(r => r.GetSyntax() is TypeDeclarationSyntax t && t.Modifiers .Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)))) { return null; // partial이 아니면 생성 불가 }
return new EntityIdInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), IsReadOnly: typeSymbol.IsReadOnly); }
private static void Execute( SourceProductionContext context, EntityIdInfo info) { var source = GenerateEntityIdSource(info); var fileName = $"{info.Namespace.Replace(".", "")}{info.TypeName}.g.cs";
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8)); }
private static string GenerateEntityIdSource(EntityIdInfo info) { var sb = new StringBuilder();
// 헤더 sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// using 문 sb.AppendLine("using System;"); sb.AppendLine("using System.Diagnostics;"); sb.AppendLine("using Cysharp.Serialization;"); sb.AppendLine();
// 네임스페이스 sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// 타입 선언 sb.AppendLine($"[DebuggerDisplay(\"{{ToString()}}\")]"); sb.Append("public "); if (info.IsReadOnly) sb.Append("readonly "); sb.AppendLine($"partial record struct {info.TypeName} : global::MyCompany.SourceGenerator.IEntityId<{info.TypeName}>, global::System.IComparable<{info.TypeName}>"); sb.AppendLine("{");
// Value 프로퍼티 sb.AppendLine(" public global::Cysharp.Serialization.Ulid Value { get; }"); sb.AppendLine();
// private 생성자 sb.AppendLine($" private {info.TypeName}(global::Cysharp.Serialization.Ulid value) => Value = value;"); sb.AppendLine();
// 팩토리 메서드 sb.AppendLine(" /// <summary>새로운 Id를 생성합니다.</summary>"); sb.AppendLine($" public static {info.TypeName} New() => new(global::Cysharp.Serialization.Ulid.NewUlid());"); sb.AppendLine(); sb.AppendLine(" /// <summary>기존 Ulid 값으로 Id를 생성합니다.</summary>"); sb.AppendLine($" public static {info.TypeName} Create(global::Cysharp.Serialization.Ulid value) => new(value);"); sb.AppendLine(); sb.AppendLine(" /// <summary>빈 Id입니다.</summary>"); sb.AppendLine($" public static {info.TypeName} Empty => new(global::Cysharp.Serialization.Ulid.Empty);"); sb.AppendLine();
// IComparable 구현 sb.AppendLine(" public int CompareTo(" + info.TypeName + " other) => Value.CompareTo(other.Value);"); sb.AppendLine();
// 비교 연산자 sb.AppendLine($" public static bool operator >({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) > 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator <({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) < 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator >=({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) >= 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator <=({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) <= 0;"); sb.AppendLine();
// ToString sb.AppendLine(" public override string ToString() => Value.ToString();");
sb.AppendLine("}");
return sb.ToString(); }}생성기 구현이 완료되었으니, Verify 스냅샷 테스트로 생성 결과를 검증합니다.
테스트
섹션 제목: “테스트”기본 생성 테스트
섹션 제목: “기본 생성 테스트”[Fact]public Task EntityIdGenerator_ShouldGenerate_BasicEntityId(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}비-partial 타입 테스트 (부정 케이스)
섹션 제목: “비-partial 타입 테스트 (부정 케이스)”[Fact]public void EntityIdGenerator_ShouldNotGenerate_WhenNotPartial(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly record struct ProductId; // partial 없음 """;
// Act string? actual = _sut.Generate(input);
// Assert actual.ShouldBeNull();}네임스페이스 테스트
섹션 제목: “네임스페이스 테스트”[Fact]public Task EntityIdGenerator_ShouldGenerate_WithDeepNamespace(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId] public readonly partial record struct OrderId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}테스트가 통과하면 실제 도메인 모델에서 생성된 Entity Id를 사용해 봅니다.
사용 예시
섹션 제목: “사용 예시”도메인 모델에서 사용
섹션 제목: “도메인 모델에서 사용”using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId]public readonly partial record struct OrderId;
// Domain/Aggregates/Orders/Order.cspublic sealed class Order{ public OrderId Id { get; private set; } public UserId CustomerId { get; private set; } public DateTime CreatedAt { get; private set; }
public static Order Create(UserId customerId) { return new Order { Id = OrderId.New(), // 새 Id 생성 CustomerId = customerId, CreatedAt = DateTime.UtcNow }; }}리포지토리에서 사용
섹션 제목: “리포지토리에서 사용”public interface IOrderRepository{ Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct); Task<IReadOnlyList<Order>> GetByCustomerIdAsync(UserId customerId, CancellationToken ct);}
public sealed class OrderRepository : IOrderRepository{ public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct) { // Ulid를 byte[]로 변환하여 DB 조회 var bytes = id.Value.ToByteArray(); // ... }}정렬 활용
섹션 제목: “정렬 활용”// Ulid의 타임스탬프 기반 정렬var orders = await repository.GetAllAsync();var sortedByCreation = orders.OrderBy(o => o.Id); // 생성 시간순 정렬
// 비교 연산자 사용if (order1.Id > order2.Id){ Console.WriteLine("order1이 더 나중에 생성됨");}확장: 다양한 Id 타입 지원
섹션 제목: “확장: 다양한 Id 타입 지원”Guid 기반 Entity Id
섹션 제목: “Guid 기반 Entity Id”// 속성에 타입 지정[EntityId(IdType = EntityIdType.Guid)]public readonly partial record struct LegacyOrderId;
// 또는 별도 속성[GuidEntityId]public readonly partial record struct LegacyOrderId;long 기반 Entity Id (자동 증가용)
섹션 제목: “long 기반 Entity Id (자동 증가용)”[EntityId(IdType = EntityIdType.Long)]public readonly partial record struct SequentialId;
// 생성 코드public readonly partial record struct SequentialId : IEntityId<SequentialId>{ public long Value { get; }
private SequentialId(long value) => Value = value;
public static SequentialId Create(long value) => new(value); // New()는 DB 자동 증가에 의존하므로 제공하지 않음}한눈에 보는 정리
섹션 제목: “한눈에 보는 정리”Entity Id 생성기의 핵심 설계를 정리합니다.
| 항목 | 설명 |
|---|---|
| 목적 | 타입 안전한 Entity Id 자동 생성 |
| 기반 타입 | Ulid (정렬 가능, 고성능) |
| 대상 | [EntityId] 속성이 붙은 partial record struct |
| 생성 항목 | Value, New(), Create(), Empty, 비교 연산자 |
| 장점 | 컴파일 타임 타입 안전성, DB 인덱스 최적화 |
Entity Id 생성기는 ObservablePortGenerator와 동일한 파이프라인 구조를 따르면서, partial record struct 필터링이라는 새로운 구문 분석 기법을 추가한 사례입니다.
FAQ
섹션 제목: “FAQ”Q1: Ulid 대신 Guid를 사용해야 하는 경우는 언제인가요?
섹션 제목: “Q1: Ulid 대신 Guid를 사용해야 하는 경우는 언제인가요?”A: 기존 시스템이 Guid 기반이고 마이그레이션 비용이 큰 경우, 또는 데이터베이스가 UUID 네이티브 타입을 제공하여 Guid가 더 효율적인 경우에는 Guid가 적합합니다. 새 프로젝트에서는 시간순 정렬이 가능하고 DB 인덱스 성능이 우수한 Ulid를 권장합니다.
Q2: partial record struct에만 [EntityId]를 적용할 수 있는 이유는 무엇인가요?
섹션 제목: “Q2: partial record struct에만 [EntityId]를 적용할 수 있는 이유는 무엇인가요?”A: 소스 생성기는 기존 타입에 코드를 추가하는 방식으로 동작하므로, partial 키워드가 필수입니다. record struct를 사용하는 이유는 값 동등성(record)과 스택 할당(struct)을 모두 확보하기 위해서입니다. partial 없이 정의된 타입에 [EntityId]를 적용하면 MapToEntityIdInfo()에서 null을 반환하여 코드가 생성되지 않습니다.
Q3: 생성된 Entity Id에 커스텀 유효성 검사를 추가할 수 있나요?
섹션 제목: “Q3: 생성된 Entity Id에 커스텀 유효성 검사를 추가할 수 있나요?”A: partial record struct이므로 사용자가 별도 파일에서 메서드를 추가할 수 있습니다. 예를 들어 public bool IsEmpty => Value == Ulid.Empty; 같은 헬퍼 메서드를 직접 정의하면 생성된 코드와 합쳐져 하나의 타입이 됩니다. 생성기가 만드는 코드와 충돌하지 않도록 Value, New(), Create(), Empty 등의 이름은 피해야 합니다.
강타입 Entity Id를 만들었지만, 이것만으로는 EF Core가 데이터베이스에 저장하는 방법을 알지 못합니다. 다음 절에서는 Entity Id와 Value Object를 DB에 투명하게 저장하기 위한 ValueConverter 생성기를 구현합니다.