현대 C# 문법을 배우는 이유
앞 장들에서 변수, 메서드, 클래스, 컬렉션, LINQ, 파일, 비동기 프로그래밍까지 다루었다. 이제 C# 코드를 읽고 쓸 기본 체력은 갖춘 셈이다. 하지만 실제 문서나 최신 프로젝트를 열어 보면 record, init, string?, with, 파일 범위 네임스페이스 같은 문법이 자주 보인다.
이 장의 목표는 새 문법을 많이 외우는 것이 아니다. 이미 배운 기본기를 바탕으로 "왜 이런 문법이 생겼는지" 이해하는 것이다. 현대 C# 문법은 대체로 세 가지 방향을 가진다.
반복되는 코드를 줄인다.
null 오류를 더 일찍 발견한다.
데이터 중심 코드를 더 명확하게 표현한다.
예를 들어 nullable 참조 형식은 C# 8.0, record와 init 접근자는 C# 9.0, 파일 범위 네임스페이스와 전역 using은 C# 10에서 도입되었다1. 버전 번호를 모두 외울 필요는 없지만, 이 기능들이 비교적 최신 C# 코드에서 자연스럽게 쓰인다는 점은 알아 두자.
AI 생성 이미지
record로 값 중심 객체 만들기
record는 데이터를 담기 위한 타입을 간결하게 정의하는 문법이다. 일반 class도 데이터를 담을 수 있지만, 값 비교, 출력 문자열, 복사 후 일부 변경 같은 코드를 직접 작성해야 하는 경우가 많다. record는 이런 코드를 컴파일러가 많이 만들어 준다.
public record Product(string Name, decimal Price);
var keyboard1 = new Product("Keyboard", 45000m);
var keyboard2 = new Product("Keyboard", 45000m);
var saleKeyboard = keyboard1 with { Price = 39000m };
Console.WriteLine(keyboard1 == keyboard2);
Console.WriteLine(keyboard1);
Console.WriteLine(saleKeyboard);실행 결과:
True
Product { Name = Keyboard, Price = 45000 }
Product { Name = Keyboard, Price = 39000 }일반 클래스는 기본적으로 같은 객체인지, 즉 같은 메모리의 대상을 가리키는지를 비교한다. 반면 record는 같은 타입이고 내부 값이 같으면 같은 값으로 본다2. 그래서 keyboard1과 keyboard2는 서로 다른 객체로 만들어졌지만 == 결과가 True다.
물론 여기서 말하는 "내부 값"도 타입에 따라 비교 방식이 달라질 수 있다. 문자열이나 숫자처럼 값 비교가 자연스러운 속성은 예상대로 비교되지만, 리스트 같은 참조형 속성은 그 타입의 기본 비교 규칙을 따른다. 그래서 record를 쓰면 모든 내부 데이터가 자동으로 깊게 비교된다고 생각하면 안 된다.
with 식도 중요하다. keyboard1을 직접 고치는 대신, 기존 값을 복사한 새 record를 만들고 Price만 바꾼다. 이런 방식을 비파괴적 변경이라고 한다. 원본을 망가뜨리지 않고 새 값을 만드는 방식이다.
record는 다음과 같은 곳에 잘 어울린다.
API 응답 데이터
설정 값
화면에 표시할 읽기 전용 데이터
LINQ 결과를 담는 간단한 모델
반대로 객체의 정체성이 중요하거나, 내부 상태가 자주 바뀌어야 하는 도메인 객체라면 일반 class가 더 자연스러울 수 있다. "데이터 자체의 값이 중요한가?"라는 질문에 그렇다고 답할 수 있으면 record를 고려하자.
init 접근자와 불변 객체의 기본
객체를 만든 뒤 아무 곳에서나 값을 바꿀 수 있으면 편해 보인다. 하지만 프로그램이 커질수록 "이 값이 언제 바뀌었지?"를 추적하기 어려워진다. 그래서 중요한 데이터는 처음 만들 때만 값을 정하고, 이후에는 바꾸지 못하게 만들기도 한다. 이런 설계 방향을 불변 객체라고 부른다.
C#의 init 접근자는 객체 초기화 시점에는 값을 넣을 수 있지만, 초기화가 끝난 뒤에는 값을 바꾸지 못하게 한다.
public class UserProfile
{
public string Name { get; init; } = "";
public string Email { get; init; } = "";
}
var user = new UserProfile
{
Name = "Minji",
Email = "[email protected]"
};
Console.WriteLine($"{user.Name} / {user.Email}");
// user.Email = "[email protected]"; // 컴파일 오류실행 결과:
Minji / [email protected]init은 private set과 비슷해 보이지만 목적이 다르다. private set은 클래스 내부에서는 나중에도 값을 바꿀 수 있다. init은 객체를 만드는 순간의 초기화에 초점을 둔다.
record의 위치 기반 선언은 기본적으로 이런 불변 스타일과 잘 맞는다.
public record Customer(string Name, string Email);위 한 줄만으로 Name, Email 값을 가진 데이터 타입을 만들 수 있다. 이때 만들어지는 속성은 객체 초기화 단계에서 값이 정해지고, 이후에는 일반적인 방식으로 다시 대입할 수 없다. 초보 단계에서는 모든 클래스를 record로 바꾸려고 하기보다, "만든 뒤 거의 바뀌지 않는 데이터"부터 record와 init을 적용해 보자.
패턴 매칭으로 조건을 읽기 쉽게 만들기
패턴 매칭은 값을 어떤 형태나 조건과 비교해 분기하는 문법이다. 기존에는 if, else if, 형 변환, 여러 비교식을 섞어 써야 했던 코드를 더 짧고 명확하게 만들 수 있다. C#의 패턴 매칭은 타입, 값, 속성, 조건 조합을 기준으로 분기할 수 있다3.
먼저 가격대 라벨을 만드는 코드를 보자.
public record Product(string Name, decimal Price);
static string GetPriceLabel(Product product)
{
return product switch
{
{ Price: < 10000m } => "저가 상품",
{ Price: >= 10000m and < 100000m } => "일반 상품",
{ Price: >= 100000m } => "고가 상품"
};
}
var mouse = new Product("Mouse", 25000m);
Console.WriteLine(GetPriceLabel(mouse));실행 결과:
일반 상품여기서 { Price: < 10000m }는 product.Price가 10000보다 작은지 확인하는 속성 패턴이다. and는 두 조건을 함께 만족해야 한다는 뜻이다. 조건문으로 쓰면 다음처럼 길어질 수 있다.
if (product.Price >= 10000m && product.Price < 100000m)
{
return "일반 상품";
}패턴 매칭은 타입에 따라 다른 처리를 할 때도 유용하다.
public abstract record Payment(decimal Amount);
public record CardPayment(decimal Amount, string CardCompany) : Payment(Amount);
public record BankTransfer(decimal Amount, string BankName) : Payment(Amount);
static string Describe(Payment payment)
{
return payment switch
{
CardPayment card => $"카드 결제: {card.CardCompany}, {card.Amount:N0}원",
BankTransfer { Amount: >= 50000m } bank => $"고액 계좌 이체: {bank.BankName}",
BankTransfer bank => $"계좌 이체: {bank.BankName}",
_ => "알 수 없는 결제"
};
}
Payment payment = new CardPayment(30000m, "BlueCard");
Console.WriteLine(Describe(payment));실행 결과:
카드 결제: BlueCard, 30,000원주의할 점은 순서다. switch 식은 위에서 아래로 검사한다. 넓은 조건을 먼저 쓰면 아래의 구체적인 조건이 실행될 기회를 잃는다. 예를 들어 BankTransfer bank => ...를 먼저 쓰면 Amount >= 50000m 조건은 도달하지 못한다. 그래서 패턴 매칭을 쓸 때는 "더 구체적인 조건을 위에, 더 넓은 조건을 아래에" 둔다고 기억하면 좋다.
nullable 참조 형식으로 null 오류 줄이기
NullReferenceException은 초보자와 실무 개발자 모두 자주 만나는 실행 오류다. null인 객체의 속성이나 메서드를 사용하려고 할 때 발생한다.
현대 C#에서는 nullable 참조 형식을 사용해 null 가능성을 코드에 표시한다. string은 null이 아니어야 하는 문자열, string?은 null일 수도 있는 문자열이라는 뜻이다. 이 표시는 실행 시 타입을 바꾸는 것이 아니라 컴파일러가 경고를 내는 데 사용된다4.
다만 nullable 참조 형식의 경고를 제대로 보려면 프로젝트에서 nullable 기능이 켜져 있어야 한다. 최신 .NET 프로젝트에는 보통 다음 설정이 들어 있다.
<Nullable>enable</Nullable>이 설정이 켜진 상태에서 다음 코드를 보자.
string? input = Console.ReadLine();
Console.WriteLine(input.Length); // 경고: input이 null일 수 있음Console.ReadLine()은 경우에 따라 null을 반환할 수 있다. 그래서 결과를 string?에 담는 것이 안전하다. 하지만 바로 input.Length에 접근하면 컴파일러는 "null일 수도 있는데 사용해도 괜찮은가?"라고 경고한다.
안전한 코드는 null 확인을 먼저 한다.
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
Console.WriteLine("입력값이 없습니다.");
return;
}
Console.WriteLine($"입력 길이: {input.Length}");실행 예:
hello
입력 길이: 5null 확인을 통과한 뒤에는 컴파일러가 input을 null이 아닌 값으로 판단한다. 이것을 null 상태 분석이라고 한다. 컴파일러가 흐름을 따라가며 "여기서는 null일 수 있는가?"를 추적하는 기능이다.
때로는 ! 연산자를 볼 수도 있다.
Console.WriteLine(input!.Length);이 코드는 "컴파일러야, 이 값은 null이 아니라고 내가 보장할게"라는 뜻이다. 하지만 실제로 null이면 여전히 런타임 오류가 난다. !는 경고를 없애는 도구이지, null을 없애는 도구가 아니다. 가능하면 null 확인, 기본값 제공, 메서드 구조 변경으로 해결하자.
파일 범위 네임스페이스와 전역 using
예전 C# 파일은 네임스페이스 때문에 중괄호가 한 단계 더 깊어지는 경우가 많았다.
namespace Shop.Models
{
public record Product(string Name, decimal Price);
}C# 10부터는 파일 전체가 하나의 네임스페이스에 속한다면 다음처럼 쓸 수 있다.
namespace Shop.Models;
public record Product(string Name, decimal Price);이것을 파일 범위 네임스페이스라고 한다. 코드의 들여쓰기가 줄어들고, 실제 타입 정의가 더 눈에 잘 들어온다.
전역 using은 여러 파일에서 반복해서 쓰는 using을 프로젝트 전체에 적용하는 기능이다.
// GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Linq;이렇게 해 두면 각 파일마다 같은 using System;을 반복해서 쓰지 않아도 된다. 최신 .NET 콘솔 프로젝트에서는 .csproj 파일에 다음 설정이 들어 있는 경우도 많다.
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>ImplicitUsings는 자주 쓰는 네임스페이스를 자동으로 포함해 주는 설정이다. 편리하지만 모든 using을 전역으로 올리는 것은 좋지 않다. 프로젝트 대부분의 파일에서 정말 자주 쓰는 것만 전역으로 두자.
흔한 실수
첫째, record를 모든 클래스의 대체품으로 생각하는 것이다. record는 값 중심 데이터에 적합하다. 객체의 상태 변화와 정체성이 중요하면 class가 더 낫다.
둘째, with가 깊은 복사를 해 준다고 착각하는 것이다. record 안에 리스트 같은 참조형 속성이 있으면, with로 복사해도 그 리스트 자체는 공유될 수 있다. 즉 새 record를 만들었더라도 내부 리스트까지 새로 만들어지는 것은 아니다.
셋째, nullable 경고를 무시하는 것이다. 경고가 있어도 실행은 될 수 있지만, 그 경고는 미래의 NullReferenceException을 미리 알려 주는 신호일 가능성이 높다.
넷째, !를 습관적으로 붙이는 것이다. !는 마지막 수단에 가깝다. 먼저 null 확인 코드를 작성하자.
다섯째, 패턴 매칭 조건의 순서를 잘못 두는 것이다. 넓은 조건은 아래쪽에, 구체적인 조건은 위쪽에 두는 편이 안전하다.
작은 연습 문제
Bookrecord를 만들어 보자. 속성은Title,Author,Price로 둔다. 같은 값을 가진 책 두 개를 만든 뒤==결과를 출력해 보자.Book하나를 만든 뒤with를 사용해 가격만 할인된 새 책을 만들어 보자. 원본 가격이 바뀌지 않았는지도 함께 출력해 보자.GetBookLabel(Book book)메서드를 만들고 패턴 매칭으로 가격이 10000원 미만이면"저렴한 책", 30000원 미만이면"보통 책", 그 이상이면"비싼 책"을 반환하게 해 보자.string? nickname = Console.ReadLine();코드를 작성한 뒤, null 또는 빈 문자열이면"닉네임 없음"을 출력하고, 값이 있으면 길이를 출력해 보자.새 파일에
namespace Practice.Models;형식의 파일 범위 네임스페이스를 사용해 간단한 record를 작성해 보자.
정리
현대 C# 문법은 기본 문법을 대체하는 별개의 기술이 아니다. 이미 배운 클래스, 조건문, 컬렉션, 예외 처리 위에서 코드를 더 짧고 안전하게 만드는 도구다.
record는 값 중심 데이터를 간결하게 표현한다. init은 객체 초기화 이후의 불필요한 변경을 줄인다. 패턴 매칭은 복잡한 조건 분기를 읽기 쉽게 만든다. nullable 참조 형식은 null 가능성을 컴파일 단계에서 드러낸다. 파일 범위 네임스페이스와 전역 using은 프로젝트 파일의 반복 코드를 줄인다.
이 기능들을 한 번에 모두 쓰려고 하지 않아도 된다. 먼저 nullable 경고를 읽고 고치는 습관을 만들고, 데이터 전달용 타입에 record를 적용해 보자. 그다음 조건문이 길어지는 곳에서 패턴 매칭을 사용하면 현대 C# 코드가 훨씬 자연스럽게 읽히기 시작한다.
참고
1The history of C# | Microsoft Learn
2Records - C# reference | Microsoft Learn
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.