값 타입 변수는 일반적으로 값 자체를 담고 있으므로 대입 시 값이 복사됩니다. 반면 참조 타입 변수는 객체를 가리키는 참조를 담고 있으므로 대입 시 참조가 복사되며, 두 변수가 동일한 객체의 상태 변화를 함께 관찰하게 됩니다. 여기서 중요한 점은 데이터가 저장되는 위치가 단순히 타입 종류뿐만 아니라 수명과 문맥에 의해서도 결정된다는 사실입니다. 메서드의 지역 변수는 스택에 머무를 수 있지만, 클래스 필드, 배열 요소, 클로저 캡처 변수, 박싱된 값, 비동기 상태 머신의 필드로 저장된 값 타입은 해당 객체와 함께 힙에 머무릅니다.
int a = 10;
int b = a;
b = 20;
// a is still 10
var first = new User { Name = "Ann" };
var second = first;
second.Name = "Kate";
// first.Name is also "Kate"
class는 객체 식별성을 갖는 참조 타입입니다. struct는 값 타입이며 좌표, 범위, 식별자처럼 크기가 작고 불변인 값에 가장 적합합니다. record class는 값 기반 동등성을 갖는 참조 타입이고, record struct는 이에 대응하는 값 타입 버전입니다. record는 일반적인 CLR (Common Language Runtime) 타입 위에 컴파일러가 제공하는 문법으로, 동등성 비교, GetHashCode, ToString, 해체(deconstruction), 그리고 비파괴적 변이를 위한 with 표현식을 포함한 복사 지원 기능을 생성합니다. 백엔드 코드에서 record는 DTO (Data Transfer Object) 성격의 모델에 유용하지만, 크기가 크고 변경 가능한 struct는 복사 및 사본 수정 과정에서 예기치 않은 동작을 유발할 수 있어 위험합니다.
public record User(int Id, string Name);
var first = new User(1, "Alice");
var second = new User(1, "Alice");
Console.WriteLine(first == second); // true
ref는 인수를 참조로 전달하며 호출 전에 반드시 초기화되어 있어야 합니다. 호출된 메서드는 해당 값을 읽거나 변경할 수 있습니다. out 역시 참조로 전달되지만, 메서드가 반환되기 전에 반드시 값을 할당해야 합니다. in은 읽기 전용 참조(readonly byref)로 전달하여 크기가 큰 구조체의 복사를 방지할 수 있지만 완전한 불변성을 보장하지는 않습니다. 변경 가능한 구조체이거나 읽기 전용이 아닌 멤버를 호출할 경우 방어적 복사가 발생할 수 있습니다. IL 수준에서 ref, out, in은 서로 다른 C# 규칙이 적용되는 관리되는 참조(managed byref) 매개변수입니다.
void Increment(ref int value) => value++;
bool TryRead(string text, out int value) =>
int.TryParse(text, out value);
double Length(in Vector vector) =>
Math.Sqrt(vector.X * vector.X + vector.Y * vector.Y);
박싱은 힙에 객체를 할당하고 그 안에 값을 복사하여 값 타입을 참조 타입 객체로 감싸는 작업입니다. 언박싱은 감싸진 값을 다시 추출하는 것이며 호환되는 정확한 타입을 필요로 합니다. 잦은 박싱은 메모리 할당을 늘리고 가비지 컬렉터에 부담을 줍니다. 제네릭을 사용하면 `List<int>`가 각 값을 `object`로 변환하지 않고 `int` 값 그대로 저장하므로 많은 박싱 상황을 피할 수 있습니다.
int number = 42;
object boxed = number; // boxing
int restored = (int)boxed; // unboxing
object value = 42;
// long wrong = (long)value; // InvalidCastException
long ok = (long)(int)value;
null 허용 값 형식은 `Nullable<T>` 또는 `T?`를 사용하며, 값 형식을 감싸는 실제 런타임 래퍼 역할을 합니다. 반면 null 허용 참조 형식은 대부분 컴파일러 분석에 가깝습니다. `string?`은 해당 참조가 null일 수 있음을 컴파일러에 알릴 뿐, 새로운 런타임 타입을 생성하지는 않습니다. null 면제 연산자(null-forgiving operator) `!`는 컴파일러 경고를 무시할 뿐이며, `NullReferenceException`을 방지해 주지는 않습니다.
int? age = null;
string name = "Alice";
string? optionalName = null;
var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";
string trusted = optionalName!;
const는 원시 타입, 열거형 값, string, null로 제한되는 컴파일 시점 상수이며, 컴파일할 때 해당 값을 참조하는 어셈블리에 값이 직접 치환됩니다. readonly는 선언부나 생성자에서만 값을 할당할 수 있는 인스턴스 필드입니다. static readonly는 타입당 하나만 존재하는 필드로, 선언부나 정적 생성자에서 할당됩니다. 라이브러리 버전에 따라 변경될 가능성이 있는 public 값은 public const보다 static readonly로 선언하는 것이 더 안전합니다. 클라이언트 코드를 다시 컴파일하기 전까지 인라인된 이전 const 값을 그대로 유지할 수 있기 때문입니다.
public const int MaxAttempts = 3;
private readonly Guid _id = Guid.NewGuid();
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);
일반적인 클래스의 경우 Equals와 ==는 기본적으로 참조를 비교합니다. 다수의 내장 타입과 record는 값을 기준으로 비교합니다. Equals를 재정의(override)했다면 GetHashCode도 일관되게 동작해야 합니다. 즉, a.Equals(b)가 true이면 두 객체는 반드시 동일한 해시 코드를 가져야 합니다. 하지만 그 역은 성립하지 않습니다. 객체가 Dictionary의 키나 HashSet의 요소로 사용되는 동안에는 동등성(equality) 비교나 해싱에 사용되는 필드를 절대로 변경해서는 안 됩니다.
public sealed class User : IEquatable<User>
{
public required int Id { get; init; }
public bool Equals(User? other) =>
other is not null && Id == other.Id;
public override bool Equals(object? obj) =>
obj is User other && Equals(other);
public override int GetHashCode() =>
Id.GetHashCode();
}
`override`는 가상 메서드의 동작을 재정의하므로, 호출되는 메서드는 객체의 런타임 타입에 따라 결정됩니다. 반면 `new`는 기반 클래스의 멤버를 숨기며(hiding), 호출되는 멤버는 변수의 정적 타입에 따라 결정됩니다. 다형성을 구현할 때는 일반적으로 `virtual`과 `override`를 사용하며, `new`를 통한 멤버 은폐는 덜 일반적이고 동작을 혼란스럽게 만들 수 있습니다.
class Base
{
public virtual void First() => Console.WriteLine("Base.First");
public void Second() => Console.WriteLine("Base.Second");
}
class Derived : Base
{
public override void First() => Console.WriteLine("Derived.First");
public new void Second() => Console.WriteLine("Derived.Second");
}
Base value = new Derived();
value.First(); // Derived.First
value.Second(); // Base.Second
추상 클래스는 상태, 생성자, 필드, 구현된 메서드, 추상 메서드, protected 멤버를 포함할 수 있습니다. 인터페이스는 계약을 정의하며, 최신 인터페이스는 기본 구현과 정적 추상 멤버를 포함할 수 있지만 여전히 인스턴스 상태를 보관하는 용도로는 사용되지 않습니다. 클래스는 하나의 기본 클래스만 상속할 수 있지만 여러 인터페이스를 구현할 수 있습니다. 공유 기반과 공유 구현에는 추상 클래스를 사용하고, 기능과 경계를 정의할 때는 인터페이스를 사용합니다.
public interface IAsyncRepository<T>
{
Task<T?> FindAsync(
int id,
CancellationToken cancellationToken);
}
제네릭을 사용하면 재사용 가능하고 타입 안전한(type-safe) 코드를 작성할 수 있습니다. 많은 검사를 컴파일 시점으로 옮겨 주고, 명시적 형변환을 제거하며, 값 타입에 대한 박싱을 방지할 수 있습니다. 제네릭 제약 조건은 클래스, 구조체, 인터페이스 구현, 기본 클래스, 비관리형 타입(unmanaged type) 또는 매개변수 없는 public 생성자 보유 여부 등 타입 매개변수가 충족해야 하는 요건을 지정합니다.
public T? Find<T>(
IEnumerable<T> items,
Predicate<T> predicate)
{
return items.FirstOrDefault(item => predicate(item));
}
public T Create<T>() where T : class, new()
{
return new T();
}
공변성은 더 일반적인 출력 타입이 필요한 위치에 더 구체적인 타입을 사용할 수 있게 해줍니다(예: IEnumerable<object> 자리에 IEnumerable<string> 사용). 인터페이스와 델리게이트(delegate)에서 out 키워드로 지정합니다. 반공변성은 입력 값에 대해 반대 방향으로 동작하며 in 키워드로 지정합니다. 가변성(variance)은 참조 타입을 사용하는 인터페이스와 델리게이트에서만 동작합니다. IEnumerable<int>는 박싱이 필요하므로 IEnumerable<object>가 될 수 없으며, List<string> 역시 List<object>에 할당할 수 없습니다.
IEnumerable<T>는 .NET의 열거형 컬렉션을 나타내며, LINQ 연산자는 대개 Func<T, bool>과 같은 델리게이트(delegate)를 받습니다. 반면 IQueryable<T>는 Entity Framework Core와 같은 공급자(provider)가 SQL (Structured Query Language)로 변환할 수 있는 Expression<Func<T, bool>> 형태의 표현식 트리(expression tree)를 저장합니다. ToList를 너무 이른 시점에 호출하면 데이터가 구체화(materialize)되어 이후의 필터링이 메모리에서 수행됩니다. 또한 모든 C# 표현식을 쿼리 공급자가 변환할 수 있는 것은 아닙니다. 의도적인 아키텍처 설계가 아니라면 IQueryable이 애플리케이션의 모든 계층으로 누출되지 않도록 해야 합니다.
상당수의 LINQ 연산자는 쿼리를 즉시 실행하지 않고 구성만 해 둡니다. 실제 실행은 쿼리가 열거(enumeration)되거나 ToList, Count, First와 같은 최종 연산(terminal operation)이 호출될 때 시작됩니다. 이는 열거가 일어나기 전에 데이터 소스가 변경되면 결과에 영향을 줄 수 있고, 반복해서 열거하면 동일한 작업이나 SQL 쿼리가 다시 실행될 수 있으며, 쿼리 구성 시점이 아닌 열거 시점에 예외가 발생할 수 있음을 의미합니다.
var query = numbers.Where(x => x > 10);
numbers.Add(42);
foreach (var number in query)
{
Console.WriteLine(number);
}
var materialized = query.ToList();
14배열(array), List<T>, Dictionary<TKey,TValue>, HashSet<T>의 차이점은 무엇인가요?
배열은 고정된 크기를 가지며 인덱스를 통한 빠른 접근을 제공합니다. List<T>는 O(1) 인덱스 접근이 가능한 동적 배열입니다. 용량(capacity)이 증가할 때 더 큰 배열을 할당하고 기존 요소를 복사하므로 끝에 요소를 추가(Add)하는 연산은 분할 상환(amortized) O(1)이며, 중간에 삽입하는 연산은 O(n)입니다. Dictionary<TKey,TValue>는 키를 값에 매핑하며 일반적으로 O(1)의 키 조회를 제공합니다. HashSet<T>는 고유한 요소를 저장하며 포함 여부 확인에 이상적입니다. 해시 기반 컬렉션의 경우 Equals와 GetHashCode를 올바르게 구현하는 것이 필수적입니다.
var ids = new HashSet<int> { 1, 2, 3 };
if (ids.Contains(userId))
{
// Fast membership check
}
15IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>의 차이점은 무엇인가요?
`IEnumerable<T>`는 순차적인 열거만을 보장합니다. `ICollection<T>`는 `Count`와 `Add`, `Remove` 같은 수정 메서드를 추가합니다. `IList<T>`는 인덱스를 통한 접근 기능을 추가합니다. `IReadOnlyCollection<T>`는 해당 인터페이스를 통해 열거와 `Count`를 제공하지만 수정 메서드는 노출하지 않습니다. API 설계 시에는 목적에 부합하는 가장 좁은 범위의 규약을 반환해야 하지만, 읽기 전용 인터페이스가 하위 객체의 완전한 불변성까지 보장하는 것은 아닙니다.
yield return은 지연 실행(deferred execution)을 지원하는 반복자(iterator)를 생성합니다. 컴파일러는 해당 메서드를 상태 머신으로 변환하며, MoveNext가 호출될 때마다 값이 하나씩 생성됩니다. 이를 통해 중간 컬렉션 생성을 피할 수 있어 대규모 시퀀스를 다룰 때 유용합니다. 주의해야 할 점은 수명과 다중 순회(multiple enumeration) 문제입니다. 이미 해제(dispose)된 DbContext나 스트림에 의존하는 지연 시퀀스를 반환해서는 안 되며, Count 호출 후 foreach를 실행하면 반복자가 두 번 실행되어 부수 효과(side effects)나 I/O가 중복 발생할 수 있음을 기억해야 합니다.
IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
foreach (var number in source)
{
if (number % 2 == 0)
yield return number;
}
}
델리게이트는 메서드에 대한 타입 안전한 참조입니다. 람다는 간결한 인라인 함수를 제공하며, 주로 Func, Action 또는 Predicate 델리게이트에 할당됩니다. 람다는 변수의 값에 대한 스냅샷이 아니라 변수 자체를 캡처하므로, 이후 반복문이나 변수의 변경된 값을 참조할 수 있습니다. 람다가 아무것도 캡처하지 않는 경우 컴파일러가 정적 델리게이트를 캐싱하여 클로저 할당을 방지할 수 있습니다. 성능이 중요한 코드에서는 클로저 생성과 이로 인한 객체 수명 연장이 문제가 될 수 있습니다.
public delegate int Operation(int x, int y);
Operation operation = (x, y) => x + y;
Console.WriteLine(operation(2, 3));
int factor = 10;
Func<int, int> multiply = x => x * factor;
`event`는 대개 내부적으로 `delegate`를 기반으로 동작하지만, 외부 코드에서 수행할 수 있는 작업을 제한합니다. 외부 코드는 구독(subscribe) 및 구독 취소(unsubscribe)만 할 수 있으며, 이벤트를 발생(raise)시키는 것은 오직 소유한 클래스만 가능합니다. 반면 public `delegate` 필드는 호출자가 호출 목록(invocation list)을 임의로 교체하거나 직접 호출할 수 있도록 허용합니다. 또한 이벤트는 게시자(publisher)에서 구독자(subscriber)로의 참조를 생성하므로, 구독자가 구독을 취소하지 않으면 수명이 긴 게시자로 인해 메모리 누수가 발생할 수 있습니다.
예외는 일반적인 제어 흐름이 아니라 예외적인 상황에만 사용해야 합니다. 직접 처리할 수 있는 예외만 잡고, 비어 있는 catch 블록을 피하며, 예외를 감싸서(wrapping) 다시 던질 때는 맥락 정보를 추가하고 사용자에게 내부 세부 정보를 노출하지 마십시오. 기존 스택 추적(stack trace)을 유지하면서 다시 던질 때는 `throw;`를 사용합니다. 예외를 감쌀 때는 `throw new DomainException("...", ex)`와 같이 원본 예외를 `InnerException`으로 전달하십시오. 예상 가능한 실패 상황에는 `TryParse` 스타일의 API를 사용하는 것이 좋습니다.
예외 필터는 추가 조건이 참(true)일 때만 `catch` 블록이 실행되도록 합니다. 필터는 스택 언와인딩(stack unwinding) 이전이자 `catch` 본문에 진입하기 전에 평가되므로, `false`를 반환할 때 원래의 호출 스택이 온전히 보존되어 진단 및 크래시 덤프 분석에 유리합니다. 필터는 HTTP 상태 코드나 데이터베이스 에러 코드 확인, 일시적인 오류 구분, 조건부 로깅 등에 유용합니다. 필터는 단순하게 유지하고 복잡한 부수 효과는 피해야 합니다.
try
{
await SendAsync();
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle only 404
}