C# Journey

  • 30 minutes to read

[실습] 처음부터 지금까지, C# 정리하기

소개

이번 실습에서는 C#의 주요 기능 발전을 단계별로 살펴보며, C# 프로그래밍 스타일이 어떻게 변화했는지를 보여줍니다. 초기 버전부터 최신 버전까지 점진적으로 개선된 코드를 통해, C#이 제공하는 다양한 기능을 정리할 수 있습니다.

따라하기: 단계별 연습을 위한 GitHub 리포지토리 생성

GitHub에 csharp-journey 이름으로 리포지토리를 생성합니다.

필자의 리포지토리는 다음 경로입니다.

https://github.com/VisualAcademy/csharp-journey

따라하기: C# 콘솔 응용 프로그램 설정

이 실습은 각 C# 버전에서 도입된 기능을 직접 실행해볼 수 있도록 설계되었습니다. 이를 위해 CsharpJourney라는 C# 콘솔 응용 프로그램을 만들어 각 단계별 기능을 테스트할 수 있습니다.

CsharpJourney 프로젝트 생성

먼저, Visual Studio 또는 .NET CLI를 사용하여 새 C# 콘솔 애플리케이션을 만듭니다.

.NET CLI를 사용하는 경우
mkdir CsharpJourney
cd CsharpJourney
dotnet new console
Visual Studio를 사용하는 경우
  1. Visual Studio를 실행합니다.
  2. 새 프로젝트 만들기에서 콘솔 앱(.NET)을 선택합니다.
  3. 프로젝트 이름을 CsharpJourney로 설정하고 생성합니다.

각 C# 버전별 기능을 단계적으로 실행

이제 Program.cs 파일을 열고, C# 1.0부터 최신 버전까지의 주요 기능을 하나씩 추가하면서 실행해 볼 수 있습니다. 각 따라하기의 코드를 작성하고 실행하면, C#이 어떻게 발전해왔는지 직접 경험할 수 있습니다.

따라하기: C# 1.0 - 기본적인 함수형 접근

C# 1.0에서는 제네릭 기능이 없었기 때문에, 컬렉션을 다룰 때 ArrayList를 사용해야 했습니다. 또한 Predicate과 같은 대리자를 직접 정의해야 했습니다.

코드: Program.cs

using System;
using System.Collections;

class Program
{
    static void Main()
    {
        int[] array = { 4, 8, 15, 16, 23 };
        int[] query = Filter(array, GreaterThanFive);
        foreach (int value in query) { Console.WriteLine(value); }
    }

    static bool GreaterThanFive(int i) { return i > 5; }

    static int[] Filter(int[] src, Predicate p)
    {
        ArrayList dst = new ArrayList();
        foreach (int value in src) { if (p(value)) dst.Add(value); }
        int[] result = new int[dst.Count];
        for (int i = 0; i < result.Length; i++) { result[i] = (int)dst[i]; }
        return result;
    }

    delegate bool Predicate(int i);
}
8
15
16
23

이 코드는 C# 1.0 환경에서 제네릭 없이 ArrayList와 사용자 정의 대리자를 활용해 5보다 큰 값만 선별하는 필터 함수를 구현하고, 해당 결과를 출력하는 예제입니다.

따라하기: C# 9.0 - 최상위 문 도입

C# 9.0에서는 최상위 문(Top-Level Statements)을 도입하여, Main 메서드 없이도 코드 실행이 가능해졌습니다. 이를 통해 C# 기본(보일러플레이트) 코드를 줄이고, 보다 간결한 스크립트 스타일의 코드를 작성할 수 있습니다.

using System;
using System.Collections;

int[] array = { 4, 8, 15, 16, 23 };
int[] query = Filter(array, GreaterThanFive);
foreach (int value in query) { Console.WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

int[] Filter(int[] src, Predicate p)
{
    ArrayList dst = new ArrayList();
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    int[] result = new int[dst.Count];
    for (int i = 0; i < result.Length; i++) { result[i] = (int)dst[i]; }
    return result;
}

delegate bool Predicate(int i);

실행 결과는 처음 코드와 동일합니다.

따라하기: C# 6.0 - using static 도입

C# 6.0에서는 using static 키워드를 활용하여 Console.WriteLine을 줄여 쓸 수 있습니다. 이를 통해 보다 깔끔한 코드를 작성할 수 있습니다.

using System;
using System.Collections;
using static System.Console;

int[] array = { 4, 8, 15, 16, 23 };
int[] query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

int[] Filter(int[] src, Predicate p)
{
    ArrayList dst = new ArrayList();
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    int[] result = new int[dst.Count];
    for (int i = 0; i < result.Length; i++) { result[i] = (int)dst[i]; }
    return result;
}

delegate bool Predicate(int i);

따라하기: C# 10.0 - Global Usings 도입

C# 10.0에서는 global using을 지원하여, 모든 파일에서 반복적으로 사용해야 하는 using 문을 별도로 지정할 수 있습니다. 이를 활용하면 코드의 가독성을 높이고 유지보수를 쉽게 할 수 있습니다.

프로젝트 Program.cs와 함께 추가로 GlobalUsings.cs 파일을 생성하고 다음과 같이 코드를 작성합니다.

코드: CsharpJourney\GlobalUsings.cs

global using System;
global using System.Collections;
global using static System.Console;

코드: CsharpJourney\Program.cs

int[] array = { 4, 8, 15, 16, 23 };
int[] query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

int[] Filter(int[] src, Predicate p)
{
    ArrayList dst = new ArrayList();
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    int[] result = new int[dst.Count];
    for (int i = 0; i < result.Length; i++) { result[i] = (int)dst[i]; }
    return result;
}

delegate bool Predicate(int i);

따라하기: C# 2.0 - 제네릭 도입

C# 2.0에서는 List<T>와 같은 제네릭이 도입되면서, ArrayList를 사용할 필요가 없어졌습니다. 이를 통해 박싱/언박싱을 줄이고 성능을 향상시킬 수 있었습니다. 또한, 제네릭 대리자IEnumerable<T>를 활용한 보다 유연한 데이터 처리가 가능해졌습니다.

제네릭을 활용한 컬렉션 처리

기존의 ArrayList 대신 List<T>를 사용하여 타입 안정성을 보장할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
int[] query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

int[] Filter(int[] src, Predicate p)
{
    List<int> dst = new List<int>();
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    int[] result = new int[dst.Count];
    for (int i = 0; i < result.Length; i++) { result[i] = dst[i]; }
    return result;
}

delegate bool Predicate(int i);

제네릭 헬퍼 함수

리스트를 배열로 변환하는 과정에서 반복적인 코드 작성을 줄이기 위해 ToArray()를 직접 활용할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
int[] query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

int[] Filter(int[] src, Predicate p)
{
    List<int> dst = new List<int>();
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    return dst.ToArray();
}

delegate bool Predicate(int i);
NOTE

C# 12.0부터는 ToArray() 대신 컬렉션 식을 써서 배열을 더 간단하게 만들 수 있어요. 예를 들어, dst.ToArray()[..dst]처럼 바꿔 쓸 수 있죠.

또, new List<int>()처럼 빈 리스트를 만들던 것도 이제는 []로 훨씬 간단하게 표현할 수 있어요.

컬렉션 식에 대한 더 많은 정보는 아래 링크를 참고해 보세요.

컬렉션 식 활용

C# 12.0의 컬렉션 식을 사용해서 최종 다듬어진 Filter 메서드의 코드는 다음과 같습니다.

int[] Filter(int[] src, Predicate p)
{
    List<int> dst = [];
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    return [.. dst];
}

부작용(Side Effect)

이번에는 프로그래밍에서 말하는 "부작용(Side Effect)"에 대해 간단히 살펴보겠습니다.

int[]처럼 배열을 반환하는 경우, 반환된 배열이 외부에서 수정될 수 있기 때문에 예기치 않은 부작용이 발생할 수 있습니다.

int[] query = Filter(array, GreaterThanFive);
query[0] = 1234; // 부작용: 출력 전에 배열 값이 변경됨
foreach (int value in query) { WriteLine(value); }
1234
15
16
23

물론 위 코드는 부작용을 인위적으로 발생시키기 위한 예시이지만, 이와 유사한 상황은 실제 개발 환경에서도 충분히 발생할 수 있습니다.

배열은 참조형이기 때문에, 반환된 배열이 외부 코드에 의해 수정되면 이후 해당 배열을 사용하는 코드의 동작 결과가 달라질 수 있습니다.
이로 인해 코드의 예측 가능성과 안정성이 낮아질 수 있으므로, 이러한 부작용에 주의할 필요가 있습니다.

IEnumerable<T> 사용

IEnumerable<T>는 필요할 때까지 데이터를 계산하거나 처리하지 않는 지연 실행(Lazy Evaluation)을 통해 성능을 최적화하고, 수정 없이 반복 처리에만 집중함으로써 코드에서 읽기 전용의 의도를 분명히 할 수 있습니다. 이를 통해 부작용 없는 안전한 데이터 흐름이 가능합니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

IEnumerable<int> Filter(IEnumerable<int> src, Predicate p)
{
    List<int> dst = [];
    foreach (int value in src) { if (p(value)) dst.Add(value); }
    return [.. dst];
}

delegate bool Predicate(int i);

제네릭 대리자 활용

제네릭 대리자를 사용하면 다양한 형식에 대해 재사용 가능한 필터링 로직을 구현할 수 있습니다. 조건을 전달하는 부분도 형식에 맞게 유연하게 처리할 수 있어, 코드의 재사용성형식 안정성을 높여줍니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter<int>(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Predicate<T> p)
{
    List<T> dst = [];
    foreach (T value in src) { if (p(value)) dst.Add(value); }
    return [.. dst];
}

delegate bool Predicate<T>(T i);

이 예제는 int뿐 아니라 string, double 등 다른 형식에도 동일한 방식으로 적용할 수 있습니다.

yield 키워드를 활용한 성능 최적화

yield return을 사용하면 데이터를 필요할 때마다 한 개씩 반환하는 방식으로 성능을 향상시킬 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Predicate<T> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

delegate bool Predicate<T>(T i);

무명 메서드(Anonymous Method) 활용

익명 메서드를 사용하면 대리자를 직접 정의하지 않고 바로 조건을 지정할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter(array, delegate (int i) { return i > 5; });
foreach (int value in query) { WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Predicate<T> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

delegate bool Predicate<T>(T i);

System.Predicate<T> 사용

기본 제공되는 Predicate<T>를 활용하면 별도의 대리자 선언 없이도 조건을 지정할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter(array, delegate (int i) { return i > 5; });
foreach (int value in query) { WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Predicate<T> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

Func<T> 사용

C# 2.0에서는 Predicate<T>뿐만 아니라 Func<T> 대리자를 활용하여 보다 직관적인 방식으로 필터링 로직을 정의할 수 있습니다. Func<T, bool>을 사용하면 람다식을 보다 쉽게 적용할 수 있으며, System.Predicate<T>와 달리 다양한 시나리오에서 활용할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
IEnumerable<int> query = Filter(array, delegate (int i) { return i > 5; });
foreach (int value in query) { WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Func<T, bool> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

Func<T, bool>을 활용하면 익명 메서드 대신 람다식을 간편하게 사용할 수 있으며, 함수형 스타일의 코드 작성이 더욱 자연스러워집니다.

따라하기: C# 3.0 - 람다식과 LINQ를 활용한 간결한 데이터 처리

C# 3.0에서는 람다식(Lambda Expressions)LINQ(Language Integrated Query) 가 도입되면서 함수형 프로그래밍 스타일이 더욱 간결해졌습니다. 이를 통해 데이터를 보다 직관적이고 선언적인 방식으로 필터링하고 변환할 수 있습니다.

기존 방식: 명시적 대리자 사용

기존 방식에서는 Predicate, Func 대리자 등을 사용하여 조건을 정의하고 필터링해야 했습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = Filter(array, GreaterThanFive);
foreach (int value in query) { WriteLine(value); }

bool GreaterThanFive(int i) { return i > 5; }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Func<T, bool> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

람다식을 활용한 간결한 코드

C# 3.0에서는 람다식을 사용하여 필터링 조건을 간단하게 정의할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = Filter(array, i => i > 5);
foreach (int value in query) { WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Func<T, bool> p)
{
    foreach (T value in src) { if (p(value)) yield return value; }
}

람다식 파이프라인을 통한 체이닝(Chaining)

람다식을 활용하면 여러 개의 필터를 파이프라인 방식으로 연결할 수도 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = Filter(Filter(array, i => i > 5), i => i % 2 == 0);
foreach (int value in query) { WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Func<T, bool> p)
{
    foreach (T value in src) { if (p(value)) yield return value; } 
}

확장 메서드를 활용한 필터링

C# 3.0에서는 확장 메서드(Extension Methods) 를 사용하여 기존 타입을 확장할 수 있습니다. 아래 예제에서는 Filter 메서드를 IEnumerable<T>에 확장 메서드로 추가하여 더 간결한 체이닝이 가능하도록 했습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = array.Filter(i => i > 5).Filter(i => i % 2 == 0);
foreach (int value in query) { WriteLine(value); }

static class MyExtensions
{
    public static IEnumerable<T> Filter<T>(
        this IEnumerable<T> src, Func<T, bool> p)
    {
        foreach (T value in src) { if (p(value)) yield return value; }
    }
}

LINQ를 활용한 데이터 필터링

C# 3.0에서는 AsQueryable()를 활용하여 LINQ 메서드를 사용할 수 있습니다. 이를 통해 SQL과 유사한 방식으로 데이터를 필터링할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = array.AsQueryable().Where(i => i > 5).Where(i => i % 2 == 0);
foreach (int value in query) { WriteLine(value); }

이와 같은 방식은 필자가 개인적으로 가장 선호하는 코드 스타일 중 하나입니다. C# 1.0 시절에는 여러 줄에 걸쳐 작성해야 했던 로직을, 이제는 세 줄의 간결한 코드로 동일하거나 더 나은 기능을 구현할 수 있습니다.

Query Syntax를 활용한 가독성 높은 코드

LINQ의 쿼리 문법(Query Syntax) 을 사용하면 SQL과 비슷한 스타일로 데이터를 처리할 수 있습니다.

int[] array = { 4, 8, 15, 16, 23 };
var query = from i in array.AsQueryable()
            where i > 5
            where i % 2 == 0
            select i;
foreach (int value in query) { WriteLine(value); }

따라하기: C# 4.0 - 선택적 매개변수를 활용

C# 4.0에서는 기본값을 가진 선택적 매개변수를 지원하여, 함수 호출 시 특정 매개변수를 생략할 수 있도록 했습니다. 이를 통해 코드의 가독성이 향상되고, 오버로드된 메서드를 줄일 수 있습니다.

아래 예제에서는 ExecuteQuery 함수의 printMessage 매개변수에 기본값 true를 설정하여, 인자를 생략하면 기본적으로 출력을 수행하도록 합니다.

int[] array = { 4, 8, 15, 16, 23 };

ExecuteQuery(array); // printMessage 기본값(true) 사용

void ExecuteQuery(int[] array, bool printMessage = true)
{
    var query = array.AsQueryable().Where(i => i > 5 && i % 2 == 0);

    if (printMessage)
        foreach (int value in query)
            WriteLine(value); // 기본적으로 값 출력
}

이 방식은 기본적인 동작을 유지하면서도 필요할 때만 특정 기능을 변경할 수 있도록 유연성을 제공합니다.

따라하기: C# 5.0 - async/await을 활용한 비동기 프로그래밍

C# 5.0에서는 async/await 키워드가 도입되어, 비동기 작업을 보다 간결하고 직관적으로 작성할 수 있게 되었습니다. 이를 통해 UI 응답성을 개선하고, 비동기 실행을 효율적으로 관리할 수 있습니다.

아래 예제에서는 ExecuteQueryAsync 메서드를 async로 선언하고, 데이터를 비동기적으로 처리하여 출력합니다.

int[] array = { 4, 8, 15, 16, 23 };

await ExecuteQueryAsync(array);

async Task ExecuteQueryAsync(int[] array, bool printMessage = true)
{
    var query = array.AsQueryable().Where(i => i > 5 && i % 2 == 0);

    if (printMessage)
        foreach (int value in query)
            await Task.Run(() => WriteLine(value)); // 비동기적으로 출력
}

이 코드에서는 Task.Run을 사용하여 각 값을 별도의 작업으로 실행함으로써, 메인 스레드를 차단하지 않고 병렬 처리할 수 있도록 구현하였습니다.

따라하기: C# 6.0 - 식 본문 메서드

C# 6.0에서는 식 본문 메서드(Expression-bodied Methods) 가 도입되어 단순한 메서드를 더욱 간결하게 작성할 수 있습니다. 이를 활용하면 코드의 가독성이 향상되고, 불필요한 {} 블록을 줄일 수 있습니다.

아래 예제에서는 ExecuteQueryAsync 메서드를 식 본문으로 변환하여 한 줄로 작성하였습니다.

int[] array = { 4, 8, 15, 16, 23 };

await ExecuteQueryAsync(array);

async Task ExecuteQueryAsync(int[] array, bool printMessage = true) =>
    await (printMessage ? Task.WhenAll(array.AsQueryable()
        .Where(i => i > 5 && i % 2 == 0)
        .Select(i => Task.Run(() => WriteLine(i)))) : Task.CompletedTask);

이 방식은 간결한 로직을 처리하는 메서드에 적합하며, 특히 람다 식과 함께 사용하면 더욱 직관적인 코드를 작성할 수 있습니다.

따라하기: C# 7.0 - 패턴 매칭을 활용한 타입 검사

C# 7.0에서는 패턴 매칭이 도입되어 is 키워드를 활용한 타입 검사를 더욱 간결하게 작성할 수 있습니다. 이를 통해 if-else 문 없이도 특정 타입을 판별하고, 조건에 맞는 로직을 실행할 수 있습니다.

아래 예제에서는 inputint[] 배열인지 확인한 후, 특정 조건을 만족하는 값만 필터링하여 출력합니다.

int[] array = { 4, 8, 15, 16, 23 };

await ExecuteQueryAsync(array);

async Task ExecuteQueryAsync(object input, bool printMessage = true)
{
    if (input is int[] array) // input이 int[] 형식인지 확인
        await (printMessage
            ? Task.WhenAll(array.AsQueryable()
                .Where(i => i > 5 && i % 2 == 0)
                .Select(i => Task.Run(() => WriteLine(i))))
            : Task.CompletedTask);
}

패턴 매칭을 활용하면 타입 변환(cast) 없이도 객체의 타입을 확인할 수 있어 코드가 더 직관적이고 간결해집니다.

따라하기: C# 8.0 - switch 식을 활용한 간결한 조건 처리

C# 8.0에서는 switch 식이 도입되어 조건 분기를 더 간결하게 작성할 수 있습니다. 특히 패턴 매칭과 결합하면 다양한 조건을 깔끔하게 처리할 수 있습니다.

아래 예제에서는 switch 식을 사용하여 입력 데이터를 검사하고, 특정 조건을 만족하면 비동기 작업을 실행합니다.

int[] array = { 4, 8, 15, 16, 23 };

await ExecuteQueryAsync(array);

// C# 8.0: switch 식 적용
async Task ExecuteQueryAsync(object input, bool printMessage = true) =>
    await (input switch
    {
        int[] array when printMessage => Task.WhenAll(array.AsQueryable()
            .Where(i => i > 5 && i % 2 == 0)
            .Select(i => Task.Run(() => WriteLine(i)))), // 조건을 만족하는 값 출력

        int[] array => Task.CompletedTask, // printMessage가 false일 때 실행 안 함

        _ => throw new ArgumentException("잘못된 입력") // 잘못된 입력 처리
    });

switch 식을 사용하면 if-else 문보다 간결하고 가독성이 뛰어난 코드 작성이 가능합니다.

따라하기: C# 9.0 - record 타입으로 불변 객체 만들기

C# 9.0에서는 record 타입이 추가되어 불변(immutable) 개체를 쉽게 만들 수 있습니다. 아래 예제에서는 QueryInputrecord로 정의하여 데이터를 변경할 때 with 식을 사용합니다.

int[] array = { 4, 8, 15, 16, 23 };

var input = new QueryInput { Data = array };

// 기존 데이터를 변경하여 새로운 record 생성
var modifiedInput = input with { Data = array.Where(i => i % 2 == 0).ToArray() };

await ExecuteQueryAsync(modifiedInput);

async Task ExecuteQueryAsync(object input, bool printMessage = true) =>
    await (input switch
    {
        QueryInput { Data: int[] array } when printMessage
            => Task.WhenAll(array.AsQueryable()
                .Where(i => i > 5 && i % 2 == 0)
                .Select(i => Task.Run(() => WriteLine(i)))),

        QueryInput => Task.CompletedTask,

        _ => throw new ArgumentException("Invalid input type")
    });

// C# 9.0: record 타입 사용
record QueryInput
{
    public int[] Data { get; init; } = Array.Empty<int>();
}

위 코드에 컬렉션 식을 적용하면 다음과 같습니다.

int[] array = { 4, 8, 15, 16, 23 };

var input = new QueryInput { Data = array };

// 기존 데이터를 변경하여 새로운 record 생성
var modifiedInput = input with { Data = [.. array.Where(i => i % 2 == 0)] };

await ExecuteQueryAsync(modifiedInput);

async Task ExecuteQueryAsync(object input, bool printMessage = true) =>
    await (input switch
    {
        QueryInput { Data: int[] array } when printMessage
            => Task.WhenAll(array.AsQueryable()
                .Where(i => i > 5 && i % 2 == 0)
                .Select(i => Task.Run(() => WriteLine(i)))),
        QueryInput => Task.CompletedTask,
        _ => throw new ArgumentException("Invalid input type")
    });

// C# 9.0: record 타입 사용
record QueryInput
{
    public int[] Data { get; init; } = [];
}

record 타입은 값 변경 시 새로운 인스턴스를 생성하는 방식으로 동작하므로, 데이터 무결성을 유지하면서도 유연한 변경이 가능합니다.

따라하기: C# 11.0 - 리스트 패턴을 활용한 데이터 매칭

C# 11.0에서는 리스트 패턴(List Patterns)이 도입되어, 배열이나 리스트에서 특정 패턴을 손쉽게 매칭할 수 있습니다. 아래 예제에서는 필터링된 데이터를 추출하고, 특정 패턴과 일치하는지 확인하는 기능을 구현합니다.

int[] array = { 4, 8, 15, 16, 23 };

var query = Filter(array, i => i > 5);
foreach (int value in query) { Console.WriteLine(value); }

IEnumerable<T> Filter<T>(IEnumerable<T> src, Predicate<T> p)
{
    foreach (T value in src)
    {
        if (p(value))
            yield return value;
    }
}

bool ContainsPattern(int[] numbers)
{
    // 첫 번째가 3, 세 번째가 5, 마지막이 30이면 true
    return numbers is [3, _, 5, .., 30]; 
}

WriteLine(ContainsPattern(array));
8
15
16
23
False

List Patterns을 활용하면 배열 내 특정 요소들의 위치와 값을 쉽게 확인할 수 있습니다. 이를 통해 조건에 맞는 데이터를 더 직관적으로 처리할 수 있습니다.

따라하기: C# 12.0 - 기본 생성자를 활용한 간결한 클래스 정의

C# 12.0에서는 기본 생성자(Primary Constructor) 기능이 도입되어, 생성자에서 필드를 선언하고 초기화하는 과정을 더욱 간결하게 작성할 수 있습니다. 이를 활용하면 불필요한 필드 선언을 줄이고, 클래스의 가독성을 높일 수 있습니다.

아래 코드에서는 QueryProcessor 클래스가 기본 생성자를 사용하여 data 배열을 직접 받아 저장합니다. 그리고 Filter 메서드를 통해 주어진 조건에 맞는 값만 필터링하여 반환합니다.

int[] array = { 4, 8, 15, 16, 23 };

var processor = new QueryProcessor(array);
var query = processor.Filter(i => i > 5);

foreach (int value in query) { WriteLine(value); }

// C# 12.0: Primary Constructor 적용
public class QueryProcessor(int[] data)
{
    public IEnumerable<int> Filter(Func<int, bool> predicate)
    {
        foreach (var value in data)
            if (predicate(value))
                yield return value;
    }
}

위 코드에서는 QueryProcessor생성자에서 매개변수를 바로 받아 필드 없이 활용하는 방식으로 설계되었습니다. 이를 통해 기존보다 코드가 더욱 간결해지며, 객체 생성 시 불필요한 필드 선언 없이 데이터를 처리할 수 있습니다.

따라하기: C# 13.0 - 더욱 유연한 데이터 처리

C# 13.0에서는 함수형 스타일을 더욱 간결하게 만들고 가변 인자를 활용한 데이터 처리가 가능하도록 개선되었습니다. 아래 예제에서는 params 키워드를 사용하여 여러 개의 IEnumerable<int>를 받아 필터링한 후, 원하는 조건에 맞는 값들만 추출하는 방식을 보여줍니다.

int[] array = { 4, 8, 15, 16, 23 };

var query = FilterAndProcess(array);

foreach (int value in query) { WriteLine(value); }

static IEnumerable<int> FilterAndProcess(params IEnumerable<int>[] numbers)
{
    return numbers.SelectMany(n => n).Where(i => i > 5).Where(i => i % 2 == 0);
}
8
16

위 코드에서는 SelectMany를 사용하여 모든 입력 컬렉션을 하나의 스트림으로 펼친 뒤, Where 메서드를 두 번 적용하여 5보다 크고 짝수인 값만 필터링합니다. 이를 통해 보다 유연하고 직관적인 데이터 처리가 가능합니다.

마무리

C#은 1.0 버전부터 최신 버전까지 점진적으로 발전해 오며, 함수형 프로그래밍 스타일과 다양한 편의 구문을 점차 수용해 왔습니다. 이번 실습을 통해 각 버전의 주요 변화와 그에 따른 코드 스타일의 흐름을 보다 쉽게 이해할 수 있었기를 바랍니다.

VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com