모던 C#
C#은 2002년 1.0 버전이 처음 출시된 이후 현재까지 꾸준히 발전해 왔으며, 2025년 기준으로는 14.0 버전까지 준비중에 있습니다. 이 책을 읽고 계신 시점에서는 이보다 더 최신 버전이 출시되었을 수도 있겠지만, 여기에서는 비교적 최근의 문법을 중심으로 C#의 주요 변화와 흐름을 정리하였습니다.
> // 모던 C#: 검색 엔진에서 "C# New Features" 등으로 검색하여 Microsoft Learn의 자료 참조
C#의 새로운 기능과 간편 구문
C#은 2002년 첫 버전이 발표된 이후, 20년이 넘는 시간 동안 꾸준히 발전해 왔습니다.
초기 버전인 C# 1.0부터 5.0까지는 언어의 기초를 다지는 구조적이고 핵심적인 기능들이 중심이었으며, 6.0 이후부터는 개발자의 생산성과 편의성을 높이기 위한 작고 실용적인 기능들이 꾸준히 추가되어 왔습니다.
이러한 기능들 중 상당수는 간편 구문(Syntactic Sugar) 또는 편의 문법이라고 불립니다. 이는 원래 여러 줄로 작성해야 했던 복잡한 코드를 보다 간단하고 직관적으로 표현할 수 있도록 도와주는 문법을 의미하며, 모던 C#의 중요한 특징 중 하나로 자리잡고 있습니다. 프로그래밍 관련 해외 문서에서도 "Syntactic Sugar" 또는 "Syntax Sugar"라는 표현이 자주 등장합니다. 공식 번역어는 없지만, 이 책에서는 이를 간편 구문 또는 편의 문법으로 표기하여 사용하고 있습니다.
특히 C# 7.0 이후 버전부터는 이러한 편의 기능들이 눈에 띄게 증가하고 있으며, 새로운 기능들을 한꺼번에 모두 익히기보다는 필요할 때마다 하나씩 익히고 적용해 나가는 점진적인 학습 방식을 권장드립니다.
이 책에서는 C# 1.0부터 13.0까지의 주요 기능들을 폭넓게 정리하였으며, 각 기능을 이해하시는 데 도움이 될 수 있도록 관련 예제와 실습도 함께 수록하였습니다.
최신 버전 정보 확인하기
C#은 앞으로도 지속적으로 발전해 나갈 것으로 예상되며, 어떤 기능이 새롭게 추가될지는 예측하기 어렵습니다.
다행히 마이크로소프트에서는 새로운 기능이 발표될 때마다 공식 문서 사이트인 Microsoft Learn을 통해 자세한 가이드를 제공합니다.
최신 버전의 C# 기능에 대한 정보는 다음 경로에서 확인하실 수 있습니다:
또한, 버전별 C# 언어 기능의 상태 및 지원 여부는 .NET 컴파일러 플랫폼(Roslyn)의 GitHub 저장소에 정리되어 있습니다.
각 기능이 어떤 버전에서 도입되었는지, 현재 어떤 상태인지 알고 싶으신 경우 다음 페이지를 참고하시면 유용합니다:
C# 버전별 주요 기능 정리
이 기능들은 필수 문법이라기보다는, 코드 가독성과 작성 편의성을 높이기 위한 간편 구문(Syntactic Sugar) 중심의 기능입니다.
C# 주요 기능 요약 표
버전 | 주요 기능 (문서 링크) | 설명 |
---|---|---|
8.0 | C# 8.0 기능 테스트 실습 C# 8.0의 새로운 기능 10가지 소개 |
C# 8.0의 주요 기능을 실습과 함께 정리한 문서입니다. |
Null 허용 연산자 | 참조 형식에서 null 사용을 명시적으로 제어합니다. | |
9.0 | 최상위 문 | Main 메서드 없이도 간결한 프로그램 진입점을 제공합니다. |
init 키워드 | 개체 초기화 시에만 설정 가능한 속성 정의를 지원합니다. | |
레코드 형식 | 불변 개체와 값 기반 비교를 지원하는 간결한 클래스 형태입니다. | |
10.0 | Global Usings | 전역 범위에서 공통 using 지시문을 한 번만 선언할 수 있습니다. |
파일 범위 네임스페이스 | 중괄호 없이 네임스페이스를 선언할 수 있어 가독성이 향상됩니다. | |
Inferred Delegate Type | 델리게이트 타입을 자동 추론하여 선언을 간단히 합니다. | |
11.0 | 부호 없는 오른쪽 시프트 연산자 | >>> 연산자를 통해 부호 없는 시프트가 가능합니다. |
required 키워드 | 특정 속성의 필수 초기화를 컴파일 시 강제합니다. | |
12.0 | 기본 생성자 | 클래스의 생성자 선언을 간결하게 만들어줍니다. |
컬렉션 식 | 간단한 구문으로 컬렉션을 초기화할 수 있습니다. | |
스프레드 연산자 | .. 구문으로 컬렉션의 요소를 확장하거나 복사합니다. |
|
Experimental 특성 사용하기 | 실험적 기능임을 표시하고, 사용 시 경고를 발생시킵니다. | |
13.0 | 부분 속성 | 속성을 partial 로 선언해 파일 분할이 가능합니다. |
Params 컬렉션 | params 에 배열이 아닌 컬렉션도 전달할 수 있습니다. |
|
새로운 이스케이프 시퀀스 (\e ) |
텍스트에서 \e 로 이스케이프 문자를 표현할 수 있습니다. |
|
ref struct 의 인터페이스 구현 |
ref struct 도 인터페이스를 구현할 수 있게 되었습니다. |
|
field 키워드 |
명시적으로 필드임을 나타내는 키워드를 제공합니다. |
[실습] 처음부터 지금까지, 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를 사용하는 경우
- Visual Studio를 실행합니다.
- 새 프로젝트 만들기에서 콘솔 앱(.NET)을 선택합니다.
- 프로젝트 이름을
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
문 없이도 특정 타입을 판별하고, 조건에 맞는 로직을 실행할 수 있습니다.
아래 예제에서는 input
이 int[]
배열인지 확인한 후, 특정 조건을 만족하는 값만 필터링하여 출력합니다.
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) 개체를 쉽게 만들 수 있습니다. 아래 예제에서는 QueryInput
을 record
로 정의하여 데이터를 변경할 때 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 버전부터 최신 버전까지 점진적으로 발전해 오며, 함수형 프로그래밍 스타일과 다양한 편의 구문을 점차 수용해 왔습니다. 이번 실습을 통해 각 버전의 주요 변화와 그에 따른 코드 스타일의 흐름을 보다 쉽게 이해할 수 있었기를 바랍니다.
장 요약
C#은 앞으로도 계속해서 진화해 나갈 것이며, 새로운 기능 역시 지속적으로 추가될 예정입니다.
이 책에서 모두 다루지 못한 최신 기능이나 향후 버전에 대한 자세한 정보는 Microsoft Learn의 C# 문서 카테고리를 참고하시기 바랍니다.
🔗 C#의 새로운 기능 안내 (Microsoft Learn)