본문으로 건너뛰기

Entity ID Generator

도메인 주도 설계에서 Guid orderIdGuid userId를 혼동하는 실수는 컴파일러가 잡아주지 않습니다. 강타입 Entity Id는 이 문제를 타입 시스템으로 해결하지만, 매번 동일한 보일러플레이트를 수작업으로 작성해야 합니다. 이 절에서는 Part 2에서 배운 소스 생성기 패턴을 활용하여, [EntityId] 속성 하나로 Ulid 기반 강타입 Id를 자동 생성하는 생성기를 구현합니다.

  1. 도메인 주도 설계에서 강타입 Entity Id의 필요성 이해
    • 원시 타입 Id가 초래하는 런타임 버그와 타입 안전성 확보 전략
  2. Ulid 기반 Entity Id 생성기 구현
    • 마커 속성, 메타데이터 추출, 코드 생성까지의 전체 파이프라인
  3. partial record struct 처리 방법 습득
    • 구문 노드 필터링과 심볼 분석에서의 partial 키워드 검증

// ❌ 원시 타입 사용 - 컴파일 타임 타입 안전성 없음
public class OrderService
{
public Order GetOrder(Guid orderId) { /* ... */ }
public User GetUser(Guid userId) { /* ... */ }
}
// 실수로 userId를 orderId 자리에 전달 → 컴파일 성공, 런타임 버그
var user = orderService.GetUser(orderId); // 컴파일 OK, 버그!
// ✅ 강타입 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를 선택한 이유를 살펴봅니다.


특성GuidUlid
크기16 bytes16 bytes
생성 속도73 ns65 ns
정렬 가능❌ 무작위✅ 타임스탬프 기반
문자열 길이36자26자
DB 인덱스비효율적효율적 (순차 삽입)
01AN4Z07BY79KA1307SR9X4MV3
|----------|---------------|
타임스탬프 무작위
(48 bits) (80 bits)
10자 16자
- 타임스탬프: 밀리초 단위, 시간순 정렬 가능
- 무작위: 충돌 방지용 랜덤 데이터
<PackageReference Include="Ulid" Version="1.3.4" />

목표 코드와 Ulid 기반 설계가 확정되었으니, 이전 절에서 정리한 7단계 워크플로우에 따라 생성기를 구현합니다.


MyCompany.SourceGenerator/
├── EntityIdGenerator.cs # 메인 생성기
├── EntityIdAttribute.cs # 속성 소스 코드 상수
├── EntityIdInfo.cs # 메타데이터 record
└── MyCompany.SourceGenerator.csproj
EntityIdInfo.cs
namespace MyCompany.SourceGenerator;
/// <summary>
/// Entity Id 생성에 필요한 메타데이터
/// </summary>
public sealed record EntityIdInfo(
string TypeName,
string Namespace,
bool IsReadOnly);
EntityIdAttribute.cs
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";
}
IEntityIdInterface.cs
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; }
}
""";
}
EntityIdGenerator.cs
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를 사용해 봅니다.


Domain/Aggregates/Orders/OrderId.cs
using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId]
public readonly partial record struct OrderId;
// Domain/Aggregates/Orders/Order.cs
public 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이 더 나중에 생성됨");
}

// 속성에 타입 지정
[EntityId(IdType = EntityIdType.Guid)]
public readonly partial record struct LegacyOrderId;
// 또는 별도 속성
[GuidEntityId]
public readonly partial record struct LegacyOrderId;
[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 필터링이라는 새로운 구문 분석 기법을 추가한 사례입니다.


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 생성기를 구현합니다.

03. EF Core 값 변환기 생성기