성능을 위한 타입 디자인
이 스킬을 사용해야 할 때
다음과 같은 경우에 이 스킬을 사용하세요:
- 새로운 타입과 API를 설계할 때
- 성능 문제를 찾기 위해 코드를 검토할 때
- 클래스, 구조체, 레코드 중에서 선택해야 할 때
- 컬렉션과 열거 가능(Enumerable) 항목을 다룰 때
핵심 원칙
- 타입을 봉인(seal)하세요 - 명시적으로 상속을 위해 설계된 경우가 아니라면
- readonly 구조체를 선호하세요 - 작고 불변인 값 타입에 사용
- 정적 순수 함수를 선호하세요 - 더 나은 성능과 테스트 용이성
- 열거를 지연시키세요 - 필요할 때까지 구체화(materialize)하지 마세요
- 불변 컬렉션을 반환하세요 - API 경계에서
클래스 기본 봉인
클래스를 봉인하면 JIT 가상화 해제가 가능해지고 API 의도가 명확해집니다.
// 권장: 상속용이 아닌 클래스는 봉인
public sealed class OrderProcessor
{
public void Process(Order order) { }
}
// 권장: 레코드도 봉인 (레코드는 클래스입니다)
public sealed record OrderCreated(OrderId Id, CustomerId CustomerId);
// 비권장: 이유 없이 봉인 해제
public class OrderProcessor // 서브클래싱 가능? 의도적인가?
{
public virtual void Process(Order order) { } // 가상 = 더 느림
}
장점:
- JIT가 메서드 호출을 가상화 해제할 수 있음
- "이것은 확장 지점이 아님"을 명확히 전달
- 우발적인 호환성 깨짐 방지
값 타입을 위한 Readonly 구조체
구조체는 불변일 때 readonly여야 합니다. 이는 방어적 복사를 방지합니다.
// 권장: 불변 값 타입을 위한 Readonly 구조체
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
// 권장: 작고 수명이 짧은 데이터를 위한 Readonly 구조체
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
// 비권장: 가변 구조체 (방어적 복사 유발)
public struct Point // Readonly 아님!
{
public int X { get; set; } // 가변!
public int Y { get; set; }
}
구조체 사용 시점
| 구조체 사용 조건 | 클래스 사용 조건 |
|---|---|
| 작음 (일반적으로 16바이트 이하) | 더 큰 객체 |
| 수명이 짧음 | 수명이 긺 |
| 자주 할당됨 | 공유 참조 필요 |
| 값 의미 체계 필요 | ID 의미 체계 필요 |
| 불변 | 가변 상태 |
정적 순수 함수 선호
부작용이 없는 정적 메서드는 더 빠르고 테스트하기 쉽습니다.
// 권장: 정적 순수 함수
public static class OrderCalculator
{
public static Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
var total = items.Sum(i => i.Price * i.Quantity);
return new Money(total, "USD");
}
}
// 사용 - 예측 가능하고 테스트 가능
var total = OrderCalculator.CalculateTotal(items);
장점:
- Vtable 조회 없음 (더 빠름)
- 숨겨진 상태 없음
- 테스트 쉬움 (순수 입력 → 출력)
- 설계상 스레드 안전
- 명시적 종속성 강제
// 비권장: 종속성을 숨기는 인스턴스 메서드
public class OrderCalculator
{
private readonly ITaxService _taxService; // 숨겨진 종속성
private readonly IDiscountService _discountService; // 숨겨진 종속성
public Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
// 이것이 실제로 무엇에 의존할까요?
}
}
// 더 나은 방법: 매개변수를 통한 명시적 종속성
public static class OrderCalculator
{
public static Money CalculateTotal(
IReadOnlyList<OrderItem> items,
decimal taxRate,
decimal discountPercent)
{
// 모든 입력이 보입니다
}
}
과용하지 마세요 - 상태나 다형성이 진짜 필요한 경우에는 인스턴스 메서드를 사용하세요.
열거 지연
필요할 때까지 열거 가능 항목을 구체화하지 마세요. 과도한 LINQ 체인을 피하세요.
// 나쁨: 조기 구체화
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.ToList() // 구체화됨!
.OrderBy(o => o.CreatedAt) // 또 다른 반복
.ToList(); // 다시 구체화됨!
}
// 좋음: 마지막까지 지연
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt)
.ToList(); // 단일 구체화
}
// 좋음: 호출자가 모든 항목을 필요로 하지 않을 수 있으면 IEnumerable 반환
public IEnumerable<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt);
// 호출자가 구체화 시점 결정
}
비동기 열거
async와 IEnumerable을 사용할 때 주의하세요:
// 나쁨: LINQ에서의 비동기 - 숨겨진 할당
var results = orders
.Select(async o => await ProcessOrderAsync(o)) // 항목당 Task!
.ToList();
await Task.WhenAll(results);
// 좋음: 스트리밍을 위해 IAsyncEnumerable 사용
public async IAsyncEnumerable<OrderResult> ProcessOrdersAsync(
IEnumerable<Order> orders,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var order in orders)
{
ct.ThrowIfCancellationRequested();
yield return await ProcessOrderAsync(order, ct);
}
}
// 좋음: 병렬 처리를 위한 배치 처리
var results = await Task.WhenAll(
orders.Select(o => ProcessOrderAsync(o)));
ValueTask vs Task
자주 동기적으로 완료되는 핫 경로에는 ValueTask를 사용하세요. 실제 I/O의 경우 그냥 Task를 사용하세요.
// 권장: 캐시되거나 동기 경로에 ValueTask 사용
public ValueTask<User?> GetUserAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user); // 할당 없음
}
return new ValueTask<User?>(FetchUserAsync(id));
}
// 권장: 실제 I/O에는 Task 사용 (더 간단, 함정 없음)
public Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
{
// 항상 데이터베이스에 접근
return _repository.CreateAsync(cmd);
}
ValueTask 규칙:
- ValueTask를 두 번 이상 await하지 마세요
- 완료 전에
.Result또는.GetAwaiter().GetResult()를 사용하지 마세요 - 확실하지 않으면 Task를 사용하세요
바이트 처리를 위한 Span과 Memory
저수준 연산에는 byte[] 대신 Span<T>와 Memory<T>를 사용하세요.
// 권장: 동기 연산에는 Span 사용
public static int ParseInt(ReadOnlySpan<char> text)
{
return int.Parse(text);
}
// 권장: 비동기 연산에는 Memory 사용
public async Task WriteAsync(ReadOnlyMemory<byte> data)
{
await _stream.WriteAsync(data);
}
// 비권장: 배열 할당 강제
public static int ParseInt(string text) // 문자열 할당됨
{
return int.Parse(text);
}
일반적인 Span 패턴
// 할당 없이 슬라이싱
ReadOnlySpan<char> span = "Hello, World!".AsSpan();
var hello = span[..5]; // 할당 없음
// 작은 버퍼를 위한 스택 할당
Span<byte> buffer = stackalloc byte[256];
// 더 큰 버퍼에는 ArrayPool 사용
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// 버퍼 사용...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
컬렉션 반환 타입
API에서는 불변 컬렉션 반환
// 권장: 불변 컬렉션 반환
public IReadOnlyList<Order> GetOrders()
{
return _orders.ToList(); // 호출자가 내부 상태를 변경할 수 없음
}
// 권장: 정적 데이터에는 고정(frozen) 컬렉션 사용 (.NET 8+)
private static readonly FrozenDictionary<string, Handler> _handlers =
new Dictionary<string, Handler>
{
["create"] = new CreateHandler(),
["update"] = new UpdateHandler(),
}.ToFrozenDictionary();
// 비권장: 가변 컬렉션 반환
public List<Order> GetOrders()
{
return _orders; // 호출자가 변경 가능!
}
내부 변경은 괜찮음
public IReadOnlyList<OrderItem> BuildOrderItems(Cart cart)
{
var items = new List<OrderItem>(); // 내부적으로는 가변
foreach (var cartItem in cart.Items)
{
items.Add(CreateOrderItem(cartItem));
}
return items; // IReadOnlyList로 반환
}
컬렉션 가이드라인
| 시나리오 | 반환 타입 |
|---|---|
| API 경계 | IReadOnlyList<T>, IReadOnlyCollection<T> |
| 정적 조회 데이터 | FrozenDictionary<K,V>, FrozenSet<T> |
| 내부 구축 | List<T>, 그 후 읽기 전용으로 반환 |
| 단일 항목 또는 없음 | T? (nullable) |
| 0개 이상, 지연 | IEnumerable<T> |
빠른 참조
| 패턴 | 이점 |
|---|---|
sealed class | 가상화 해제, 명확한 API |
readonly record struct | 방어적 복사 없음, 값 의미 체계 |
| 정적 순수 함수 | Vtable 없음, 테스트 가능, 스레드 안전 |
.ToList() 지연 | 단일 구체화 |
핫 경로에 ValueTask | Task 할당 회피 |
바이트에 Span<T> | 스택 할당, 복사 없음 |
IReadOnlyList<T> 반환 | 불변 API 계약 |
FrozenDictionary | 정적 데이터에 가장 빠른 조회 |
안티 패턴
// 비권장: 이유 없는 봉인 해제 클래스
public class OrderService { } // 봉인하세요!
// 비권장: 가변 구조체
public struct Point { public int X; public int Y; } // Readonly로 만드세요
// 비권장: 정적이 될 수 있는 인스턴스 메서드
public int Add(int a, int b) => a + b; // Static으로 만드세요
// 비권장: 여러 ToList() 호출
items.Where(...).ToList().OrderBy(...).ToList(); // 끝에 하나의 ToList
// 비권장: 공개 API에서 List<T> 반환
public List<Order> GetOrders(); // IReadOnlyList<T> 반환
// 비권장: 항상 비동기인 연산에 ValueTask 사용
public ValueTask<Order> CreateOrderAsync(); // 그냥 Task 사용