C# 13 및 .NET 9 필수 기능 소개

.NET 9는 많은 변화와 개선 사항을 제공하며, 곧 출시를 앞두고 있습니다. 이 글에서는 .NET 9와 C# 13에서 가장 영향을 많이 미치고 널리 적용 가능한 주요 기능들을 살펴보겠습니다.

1. 새로운 Lock 객체

C# 13에서는 System.Threading.Lock라는 새로운 타입이 도입되어 상호 배제를 처리합니다. 기존에는 object 타입을 사용해 잠금을 구현했지만, 이제는 전용 Lock 타입이 제공되어 앞으로 대부분의 잠금 작업에 표준으로 자리 잡을 것으로 기대됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 기존 방식 (Before)
public class LockExample
{
private readonly object _lock = new(); // 잠금을 위한 object 인스턴스 생성

public void DoStuff()
{
lock (_lock) // object를 이용한 잠금
{
Console.WriteLine("기존 방식의 lock 블록 안입니다.");
}
}
}

// .NET 9 방식
public class LockExample
{
private readonly Lock _lock = new(); // 새로운 Lock 객체 생성

public void DoStuff()
{
lock (_lock) // Lock 객체를 이용한 잠금
{
Console.WriteLine(".NET 9 방식의 lock 블록 안입니다.");
}
}
}

주요 장점

  • 더 깔끔하고 안전한 코드: 코드가 더욱 읽기 쉽고 예측 가능해집니다. 또한, Lock 인스턴스를 일반 object로 잘못 사용하면 컴파일러가 경고를 제공합니다.
  • 성능 향상: Microsoft에 따르면, 임의의 object 인스턴스를 잠금에 사용하는 것보다 더 효율적일 수 있습니다.
  • 새로운 잠금 메커니즘: EnterScope가 내부적으로 Monitor 클래스를 대체합니다. 이 메커니즘은 Dispose 패턴을 따르는 ref struct를 반환하므로 using 문과 매끄럽게 결합됩니다.
  • 비동기 작업의 제한: lock 블록 내에서는 여전히 async 호출이 허용되지 않습니다. 이는 잠금과 비동기 코드가 상호 작용하는 방식에 내재된 한계 때문입니다. 기존의 SemaphoreSlim 접근 방식이 여전히 대안으로 사용됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class LockExample
{
private readonly Lock _lock = new();
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task DoStuff(int val)
{
// 1. 'lock' 구문과 비동기 작업의 제한
lock(_lock)
{
await Task.Delay(1000);
// 컴파일 오류: 'lock' 블록 내부에서 'await'를 사용할 수 없습니다.
}

// 2. 'EnterScope'와 비동기 작업의 제한
using(_lock.EnterScope())
{
await Task.Delay(1000);
// 런타임 오류: 'System.Threading.Lock.Scope' 타입 인스턴스는 'await' 또는 'yield' 경계를 넘을 수 없습니다.
}

// 3. SemaphoreSlim을 이용한 비동기 작업
await _semaphore.WaitAsync();
try
{
await Task.Delay(10);
// 정상적으로 동작: SemaphoreSlim은 비동기 작업을 지원합니다.
}
finally
{
_semaphore.Release(); // 반드시 자원을 해제해야 함
}
}
}

2. Task.WhenEach

다양한 시간 간격으로 완료되는 작업(Task) 리스트가 있다고 가정해봅시다. 작업이 모두 끝날 때까지 기다리는 WaitAll() 방식은 이 경우 적합하지 않습니다. 각각의 작업이 완료되는 즉시 처리하고 싶다면 Task.WaitAny()를 사용하여 대안적으로 구현할 수 있습니다. 그러나 C# 13에서는 이를 더 우아하고 효율적으로 처리할 수 있는 Task.WhenEach 기능이 도입되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 랜덤한 간격으로 완료되는 5개의 작업 리스트 생성
var tasks = Enumerable.Range(1, 5)
.Select(async i =>
{
await Task.Delay(new Random().Next(1000, 5000)); // 1~5초 사이의 딜레이
return $"Task {i} done"; // 완료 메시지 반환
})
.ToList();

// 기존 방식 (Before)
while(tasks.Count > 0) // 아직 완료되지 않은 작업이 남아 있는 동안
{
var completedTask = await Task.WhenAny(tasks); // 가장 먼저 완료된 작업 선택
tasks.Remove(completedTask); // 완료된 작업 리스트에서 제거
Console.WriteLine(await completedTask); // 작업 결과 출력
}

// .NET 9 방식
await foreach (var completedTask in Task.WhenEach(tasks)) // 작업이 완료될 때마다 처리
Console.WriteLine(await completedTask); // 작업 결과 출력

Task.WhenEachIAsyncEnumerable<Task<TResult>>를 반환하며, await foreach를 사용해 작업이 완료되는 즉시 쉽게 반복(iterate) 처리할 수 있도록 해줍니다.👌

3. params Collections

C# 13부터 params 매개변수로 컬렉션 표현식에 지원되는 모든 타입을 사용할 수 있게 되었습니다.

1
2
3
// 기존 방식 (Before)
static void WriteNumbersCount(params int[] numbers)
=> Console.WriteLine(numbers.Length); // int 배열만 허용

C# 13 이후, params 매개변수는 다양한 컬렉션 타입을 지원합니다.

1
2
3
4
5
6
7
8
9
10
11
12
// .NET 9
// ReadOnlySpan<int> 사용
static void WriteNumbersCount(params ReadOnlySpan<int> numbers) =>
Console.WriteLine(numbers.Length);

// IEnumerable<int> 사용
static void WriteNumbersCount(params IEnumerable<int> numbers) =>
Console.WriteLine(numbers.Count());

// HashSet<int> 사용
static void WriteNumbersCount(params HashSet<int> numbers) =>
Console.WriteLine(numbers.Count);
  • 더 깔끔한 코드: .ToArray(), .ToList() 호출 횟수를 크게 줄일 수 있습니다.
  • 성능 향상: .ToArray(), .ToList() 같은 호출은 자체적으로 추가적인 리소스 오버헤드를 발생시킵니다. 이제 Span<>IEnumerable<>를 지원함으로써 더 효율적인 메모리 사용과 지연 실행(lazy execution)을 활용할 수 있습니다. 결과적으로, 유연성과 성능이 요구되는 시나리오에서 더 나은 성능을 제공합니다.

4. Semi-Auto Properties (반자동 속성)

C#에서 public int Number { get; set; }와 같은 자동 구현 속성을 선언하면, 컴파일러가 자동으로 백업 필드(예: _number)와 내부 getter/setter 메서드(void set_Number(int number), int get_Number())를 생성합니다.

하지만 속성의 getter나 setter에서 유효성 검사, 기본값 설정, 계산, 지연 로딩(lazy loading) 등의 커스텀 로직이 필요할 경우, 클래스에서 백업 필드를 직접 정의해야 했습니다.

C# 13에서는 field 키워드를 도입하여, 백업 필드를 직접 정의하지 않고도 바로 사용할 수 있도록 간소화했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 기존 방식
public class MagicNumber
{
private int _number; // 백업 필드 직접 정의

public int Number
{
get => _number * 10; // 커스텀 로직 적용
set {
if (value < 0) // 유효성 검사
throw new ArgumentOutOfRangeException(nameof(value), "값은 0보다 커야 합니다.");
_number = value;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// .NET 9 방식
public class MagicNumber
{
public int Number
{
get => field; // 컴파일러가 생성한 백업 필드에 직접 접근
set {
if (value < 0) // 유효성 검사
throw new ArgumentOutOfRangeException(nameof(value), "값은 0보다 커야 합니다.");
field = value; // field 키워드로 백업 필드 설정
}
}
}
  • 보일러플레이트 코드 감소: 백업 필드를 수동으로 정의할 필요가 없어져 코드가 더 깔끔하고 간결해집니다.
  • 가독성 향상: field 키워드를 표준으로 사용하면서, 커스텀 백업 필드 이름을 관리할 필요가 없어 코드의 명확성이 높아집니다.
  • 속성 범위 내 필드 제한: 백업 필드는 속성 내부로 제한되어 클래스의 다른 부분에서 의도치 않게 사용되는 일을 방지하며, 캡슐화를 강화합니다.
  • 🚨 잠재적 호환성 문제: 클래스에 이미 field라는 이름의 속성이 있다면 새 키워드보다 우선 적용되어 예기치 않은 동작이 발생할 수 있습니다. 이는 이 기능이 2016년 최초 제안 이후 지연된 이유 중 하나로 보입니다.

5. Hybrid Cache

새로운 HybridCache API는 기존의 IDistributedCacheIMemoryCache API에서 발생하는 문제를 해결하며, 새로운 기능과 성능을 제공해 .NET에서 캐싱을 더 유연하고 효율적으로 만듭니다. 특히, 스탬피드 문제와 같은 캐싱의 한계를 개선하며 대부분의 IDistributedCacheIMemoryCache 시나리오에 드롭인(dop-in) 방식으로 대체할 수 있도록 설계되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public record Post(int UserId, int Id, string Title, string Body);

public class PostsService(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IDistributedCache distributedCache,
HybridCache hybridCache)
{
public async Task<List<Post>?> GetUserPostsAsync(string userId)
{
var cacheKey = $"posts_{userId}";

// 기존 방식 (Memory Cache)
var posts = await memoryCache.GetOrCreateAsync(cacheKey,
async _ => await GetPostsAsync(userId));

// 기존 방식 (Distributed Cache)
var postsJson = await distributedCache.GetStringAsync(cacheKey);
if (postsJson is null)
{
posts = await GetPostsAsync(userId);
await distributedCache.SetStringAsync(cacheKey, JsonSerializer.Serialize(posts));
}
else
{
posts = JsonSerializer.Deserialize<List<Post>>(postsJson);
}

// .NET 9 (Hybrid Cache)
posts = await hybridCache.GetOrCreateAsync(cacheKey,
async _ => await GetPostsAsync(userId), new HybridCacheEntryOptions() {
Flags = HybridCacheEntryFlags.DisableLocalCache | // 분산 캐시처럼 동작
HybridCacheEntryFlags.DisableDistributedCache // 메모리 캐시처럼 동작
});

return posts;
}

private async Task<List<Post>?> GetPostsAsync(string userId)
{
Console.WriteLine("===========Fetching posts from API");
var url = $"https://jsonplaceholder.typicode.com/posts?userId={userId}";
var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<Post>>();
}
}
  • 두 가지 장점의 결합 (Best of Both Worlds): HybridCache는 단일 API로 데이터를 메모리 캐시(L1) 또는 분산 캐시(L2)에 저장할 수 있는 유연성을 제공합니다. L1 캐시는 자주 사용되는 데이터를 빠르게 로컬에서 액세스할 수 있도록 하고, L2 캐시는 대규모 및 덜 자주 접근되는 데이터를 처리할 수 있는 확장성을 제공합니다. 이 동작은 HybridCacheEntryFlags로 제어할 수 있습니다.
  • 스탬피드 보호 (Stampede Protection): IMemoryCacheIDistributedCache 모두 스탬피드 문제를 겪지만, HybridCache는 동일한 키에 대해 하나의 호출만 값 생성을 수행하고, 다른 호출은 결과를 대기하도록 처리해 불필요한 캐시 재생성을 방지합니다.
  • 추가 기능: HybridCache는 태깅(Tagging), .WithSerializer(...).WithSerializerFactory(...) 메서드를 통한 설정 가능한 직렬화, [ImmutableObject(true)] 어노테이션을 활용한 캐시 인스턴스 재사용과 같은 추가 기능을 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 분산 캐시 (Redis) 설정
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
options.InstanceName = "SampleInstance";
});

// 메모리 캐시 설정 (데모 목적)
builder.Services.AddMemoryCache();

// HybridCache 추가
builder.Services.AddHybridCache();
builder.Services.AddSingleton<PostsService>(); // PostsService 등록

HybridCache는 메모리 캐시와 분산 캐시의 장점을 결합하여 빠른 액세스와 확장성을 동시에 제공합니다. 스탬피드 문제를 해결하며, 다양한 설정 및 추가 기능으로 유연하고 강력한 캐싱 솔루션을 제공합니다. 🚀

6. 내장 OpenAPI 문서 생성

.NET 5부터 Web API 템플릿은 Swashbuckle.AspNetCore 패키지를 통해 OpenAPI 지원을 기본으로 제공해왔습니다.

.NET 9에서는 Microsoft가 자체적으로 개발한 Microsoft.AspNetCore.OpenApi 패키지를 통해 OpenAPI 사양을 지원하며, 이는 기존의 Swashbuckle.AspNetCore를 대체합니다.

1
2
3
4
5
6
7
8
9
10
11
// 기존 방식 (Before)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

.NET 9에서는 더 간단한 방식으로 OpenAPI 문서를 설정할 수 있습니다.

1
2
3
4
5
6
7
8
// .NET 9 방식
builder.Services.AddOpenApi(); // OpenAPI 지원 추가
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // OpenAPI 엔드포인트 매핑
}

앱을 실행한 후 /openapi/v1.json으로 이동하면 생성된 OpenAPI 문서를 확인할 수 있습니다.

  • Swagger UI: 문법이 더 짧아지고 처음 보기에 더 "네이티브"하게 보이지만, 기본적으로는 상호작용 가능한 API 문서(Swagger UI)는 제공되지 않고 OpenAPI 문서만 생성됩니다. 😢 Swagger UI 같은 상호작용 가능한 API 문서가 필요하다면 Scalar와 같은 서드파티 도구를 통합해야 합니다. 자세한 가이드는 Scalar .NET API Reference Integration에서 확인할 수 있습니다.

  • Build-Time Generation: Microsoft.Extensions.ApiDescription.Server 패키지를 사용해 빌드 시점에 OpenAPI 문서를 생성할 수도 있습니다.

7. SearchValues 개선 사항

SearchValues는 .NET 8에서 도입된 불변(immutable) 및 읽기 전용 값 집합으로, 기존의 ICollection.Contains보다 훨씬 더 효율적인 검색을 제공합니다. 처음에는 문자(char)나 바이트(byte) 집합만 지원했지만, .NET 9에서는 문자열(string)도 지원하도록 확장되었습니다.

1
2
3
4
5
6
7
8
9
var text = "Exploring new capabilities of SearchValues!".AsSpan();

// 기존 방식
var vowelSearch = SearchValues.Create([ 'n', 'e', 'w' ]); // 문자 집합 검색
Console.WriteLine(text.ContainsAny(vowelSearch));

// .NET 9 방식
var keywordSearch = SearchValues.Create(["new", "of"], StringComparison.OrdinalIgnoreCase); // 문자열 검색
Console.WriteLine(text.ContainsAny(keywordSearch));

.NET 9에서는 StringComparison 매개변수를 사용해 비교 방식을 지정할 수 있습니다.

이제 문자열도 지원하며, 대소문자 무시 등의 비교 옵션을 지정할 수 있는 기능이 추가되었습니다. 앞으로 이 기능은 문서 파싱, 입력 필터링, 스팸 감지, 데이터 편집, 검색 등 광범위한 텍스트 처리 애플리케이션에서 필수적인 도구가 될 것입니다. 🚀

8. 새로운 LINQ 메서드

.NET 9에서는 CountBy, AggregateBy, Index라는 세 가지 새로운 LINQ 메서드가 추가되었습니다. 이 메서드들은 일반적인 데이터 조작 작업에서 성능과 간결성을 향상시키도록 설계되었습니다. 아래는 각 메서드의 예시와 설명입니다.

CountBy

특정 키로 그룹화하고 각 그룹의 항목 수를 계산합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
(string firstName, string lastName)[] people =
[
("John", "Doe"),
("Jane", "Peterson"),
("John", "Smith"),
("Mary", "Johnson"),
("Nick", "Carson"),
("Mary", "Morgan")
];

// 기존 방식
var firstNameCounts = people
.GroupBy(p => p.firstName)
.ToDictionary(group => group.Key, group => group.Count())
.AsEnumerable();

// .NET 9 방식
firstNameCounts = people
.CountBy(p => p.firstName);

foreach(var entry in firstNameCounts)
{
Console.WriteLine($"First Name {entry.Key} appears {entry.Value} times");
}

/*
출력:
First Name John appears 2 times
First Name Jane appears 1 times
First Name Mary appears 2 times
First Name Nick appears 1 times
*/

AggregateBy

그룹화된 데이터에서 값들을 집계합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
(string name, string department, int vacationDaysLeft)[] employees =
[
("John Doe", "IT", 12),
("Jane Peterson", "Marketing", 18),
("John Smith", "IT", 28),
("Mary Johnson", "HR", 17),
("Nick Carson", "Marketing", 5),
("Mary Morgan", "HR", 9)
];

// 기존 방식
var departmentVacationDaysLeft = employees
.GroupBy(emp => emp.department)
.ToDictionary(group => group.Key, group => group.Sum(emp => emp.vacationDaysLeft))
.AsEnumerable();

// .NET 9 방식
departmentVacationDaysLeft = employees
.AggregateBy(emp => emp.department, 0, (acc, emp) => acc + emp.vacationDaysLeft);

foreach (var entry in departmentVacationDaysLeft)
Console.WriteLine($"Department {entry.Key} has a total of {entry.Value} vacation days left.");

/*
출력:
Department IT has a total of 40 vacation days left.
Department Marketing has a total of 23 vacation days left.
Department HR has a total of 26 vacation days left.
*/

Index

컬렉션의 각 항목에 인덱스를 매핑합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var managers = new[]
{
"John Doe",
"Jane Peterson",
"John Smith"
};

// 기존 방식
foreach (var (index, manager) in managers.Select((m, i) => (i, m)))
Console.WriteLine($"Manager {index}: {manager}");

// .NET 9 방식
foreach (var (index, manager) in managers.Index())
Console.WriteLine($"Manager {index}: {manager}");

/*
출력:
Manager 0: John Doe
Manager 1: Jane Peterson
Manager 2: John Smith
*/

가장 좋은 함수는 Index()입니다. foreach에서 인덱스가 없는 점은 항상 골칫거리였고, 종종 더 복잡한 우회 방법을 사용하게 만들었기 때문입니다.

9. 내장 UUID v7 생성

.NET 초기부터 Guid.NewGuid()를 사용해 UUID를 생성해왔습니다. 이 방식은 UUID v4를 생성합니다. 그러나 UUID 사양은 지속적으로 발전해 현재의 안정된 버전은 UUID v7입니다.

UUID v7의 주요 특징 중 하나는 UUID에 포함된 타임스탬프(timestamp)입니다. 구조는 다음과 같습니다:

1
2
3
+------------------+---------------+----------------------+
| 48-bit timestamp | 12-bit random | 62-bit random |
+------------------+---------------+----------------------+

이 타임스탬프 덕분에 UUID를 생성 시간에 따라 정렬할 수 있습니다. 이는 데이터베이스에서 더욱 적합하며, 분산 환경에서 더 나은 고유성을 보장합니다.

이제 .NET에서는 외부 라이브러리(예: UUIDNext)를 사용하지 않고도 UUID v7을 생성할 수 있습니다. 새로운 Guid.CreateVersion7() 메서드가 이를 지원하며, 특정 타임스탬프를 받아 UUID를 생성할 수도 있습니다. 이는 테스트 목적이나 정렬된 시퀀스에 특정 위치에 항목을 삽입할 때 유용합니다.

1
2
3
var guid = Guid.NewGuid(); // v4 UUID
guid = Guid.CreateVersion7(); // v7 UUID 생성
guid = Guid.CreateVersion7(TimeProvider.System.GetUtcNow()); // 타임스탬프가 포함된 v7 UUID 생성
  • Guid.CreateVersion7()는 내부적으로 NewGuid()를 사용하며, 48비트 타임스탬프를 추가하고 UUID v7 표준에 맞게 올바른 버전 및 변형 비트를 설정합니다.
  • 이로 인해 NewGuid()보다 약간 느릴 수 있지만, 수백만 개의 UUID를 생성해야 하는 경우가 아니라면 성능 차이는 거의 느껴지지 않습니다.

10. 기타 기능

아래는 흥미로운 변경 사항들의 목록으로, 특정한 사용 사례에 적합하며 널리 채택되기보다는 특정 상황에서 유용하게 사용될 수 있는 기능들입니다.

Share