본문 바로가기
C#

“C# 값 형식에도 없음(null)을 — T? = Nullable<T> 제대로 쓰는 법”

by 공부봇 2025. 10. 10.
반응형

1) 왜 생겼는가 — “값 형식도 값이 없을 수 있다”

  • 문제 배경: 기본값(Value type)은 원래 null을 가질 수 없어요. 예를 들어 int는 0, 1, -5 같은 “숫자”만 가능하고 “값 없음”을 표현할 방법이 없었습니다.
  • 필요성: DB의 NULL, 폼 입력의 미입력, 센서값 미수집 같은 “없음” 상태를 정확히 모델링해야 할 때가 많습니다.
  • 해결: T?(여기서 T는 값 형식) 를 도입해 null을 담을 수 있게 했습니다. 즉, int?, bool?, DateTime? 처럼 “값이거나(null이 아닐 때) 없다(null일 때)”를 표현합니다.

2) 무엇인가 — T?는 사실 Nullable<T>의 문법 설탕

  • 정의: T?는 값 형식 T에 대해 Nullable<T>로 컴파일됩니다.
  • 구성: Nullable<T>는
    • HasValue : 값이 있는지 여부(bool)
    • Value : 실제 값(T) — 단, 값이 없으면 접근 시 예외
    • GetValueOrDefault() : 값이 있으면 그 값, 없으면 default(T) 반환
  • 주의 (이름이 비슷한 다른 것들)문법 의미
    T? (값 형식에만) Nullable<T> (예: int?)
    T? (참조 형식에) nullable reference type 주석(경고/분석용, 런타임 표현 아님)
    ?. null-조건부 연산자(안전 호출)
    ?? / ??= null 병합 / null 병합 할당

3) 언제/어떻게 쓰는가 — 사용 패턴과 미니 예제(MWE)

3-1. 기본 선언과 사용

int? a = null;        // 값이 없음을 표현
int? b = 10;          // 값이 있음

Console.WriteLine(a.HasValue);    // False
Console.WriteLine(b.HasValue);    // True

// 안전한 꺼내기
int x = b.GetValueOrDefault();    // 10
int y = a.GetValueOrDefault();    // 0 (int의 default)

// 빈 값에 Value 접근하면 예외! (InvalidOperationException)
// int z = a.Value; // ❌ 조심

3-2. null 병합 연산자 ??로 기본값 주기

int? readCount = null;
// 값이 없으면 0을 사용
int count = readCount ?? 0;  // 0

3-3. 패턴 매칭으로 값 추출 (가독성/안전성 ↑)

int? maybeAge = 29;

if (maybeAge is int age) // 값이 있으면 묶어서 꺼냄
{
    Console.WriteLine($"성인 여부: {age >= 18}");
}

3-4. 연산 “리프팅(lifting)”

  • 산술/비교/논리 연산은 둘 중 하나라도 null이면 결과가 null(또는 규칙에 따라) 이 되도록 자동 “리프팅”됩니다.
int? p = 3;
int? q = null;
int? sum = p + q;  // null
bool? cmp = p > q; // null (q가 없음)

3-5. 메서드/DB 모델에서의 실전 사용

public record UserDto(int Id, string Name, DateTime? Birthday);

// 생일이 없을 수도 있는 사용자를 받는다
void SaveUser(UserDto user)
{
    // DB 컬럼이 NULL 허용이면 그대로 매핑
    var birthday = user.Birthday?.Date; // null이면 여기서도 null
}

3-6. 컬렉션과의 조합

var scores = new List<int?> { 10, null, 30 };
// null을 제외한 평균
double avg = scores.Where(s => s.HasValue).Average(s => s!.Value);

4) 자주 하는 실수와 베스트 프랙티스

✅ 체크리스트

  • “참조형 ?”와 “값형 ?” 구분: string?은 정적분석용 주석, int?는 진짜 Nullable<int>.
  • Value 직접 접근 지양: HasValue 확인 후 접근하거나 ??, GetValueOrDefault, 패턴 매칭 사용.
  • 연산 리프팅 이해: 피연산자 중 null이 섞이면 결과가 null일 수 있음.
  • API 경계에서 명확히: DTO/DB 모델에 “없을 수 있음”을 T?로 표현.
  • 컬렉션 타입 신중히: List<int?> vs List<int> 의미가 다름.
  • 박싱 주의(성능): Nullable<T>를 object로 캐스팅하면 값이 있으면 박싱, 없으면 null 참조가 됩니다.
  • 기본값 초기화 전략: ??로 디폴트 제공, 혹은 required/검증 로직 병행.

흔한 실수들

  1. ?.와 T? 혼동
    • obj?.Prop는 “안전 호출” 문법이고, int?는 타입 자체에 null을 허용하는 것—전혀 다른 개념입니다.
  2. Value 남용으로 예외
    • a.Value 전에 반드시 a.HasValue 검사하거나, 더 간단히 a ?? 기본값 사용하세요.
    int safe = a ?? -1; // 가장 안전하고 간결
    
  3. Nullable Reference Type 경고와 뒤섞기
    • 컴파일러의 NRT 경고(예: string?)는 설계 의도와 사용 계약을 명확히 하는 용도입니다. 값 형식의 T?와는 별개로 생각하세요.
  4. 연산 결과가 null이 되는 걸 간과
    • 예: int? sum = price + tax;에서 둘 중 하나라도 null이면 sum은 null. 이후 로직에서 ?? 0으로 기본값을 부여할지 결정하세요.

소소한 팁(가독성/성능/안전)

  • 패턴 매칭 우선: if (n is int v)가 HasValue/Value보다 읽기 좋아요.
  • 초기 변환 시점 분리: 입력 직후 var amount = input.Amount ?? 0;로 비즈니스 로직에서 null 분기를 줄이세요.
  • LINQ에서 Select보다 Where를 먼저: Where(x => x.HasValue).Select(x => x.Value)가 NRE를 방지합니다.
  • 박싱 줄이기: 성능 민감 코드에선 object 캐스팅을 피하고 제네릭 경로 유지 (where T : struct)를 고려하세요.

보너스: 미니 종합 예제

using System;
using System.Collections.Generic;
using System.Linq;

public static class Demo
{
    public static void Main()
    {
        int? read = null;
        int total = 100 + (read ?? 0);      // null이면 0으로 합산
        Console.WriteLine(total);           // 100

        int? maybe = 42;
        if (maybe is int value)
        {
            Console.WriteLine(value * 2);   // 84
        }

        var xs = new List<int?> { 1, null, 3, 5 };
        double avg = xs.Where(x => x.HasValue).Average(x => x!.Value);
        Console.WriteLine(avg);             // 3
    }
}

 

반응형