공부/C#

Nullable, 확장 메서드, 메서드 체이닝

월러비 2025. 8. 19. 17:19

null 가능(Nullable) 값 타입

개념과 필요성

  • 값 형식(Value Type)은 일반적으로 null을 표현할 수 없음
    • 구조체 들
  • 참조 형식(Reference Type)은 null로 존재하지 않는 값을 표현 가능함
    • 클래스 들
string s = null;    // OK, 참조 형식
int i = null;       // 컴파일 에러, 값 형식은 null이 될 수 없음

  • 특수한 구문을 사용하여 값 형식에도 null 표현이 가능함
    • nullable int 형이라고 불러야한다. (그냥 int형이 아니다)
    • 작성하면 쓸 수 있는 프로퍼티가 제공된다.
  • 타입명 뒤에 ? 기호를 붙여 Nullable 타입을 표현함
int? i = null;      // OK
Console.WriteLine(i == null);  // True

Nullable<T> 구조체

  • Nullable 타입은 System.Nullable<T> 구조체로 구현됨
  • 두 개의 필드만을 가진 경량 구조체임
    • Value: 실제 값을 저장
    • HasValue: 값의 존재 여부를 저장
      • true면 null이 아니고, false면 null이다.
public struct Nullable<T> where T : struct
{
    public T Value { get; }
    public bool HasValue { get; }
    public T GetValueOrDefault();
    public T GetValueOrDefault(T defaultValue);
}

  • 기본적인 사용 예시:
int? i = null;
Console.WriteLine(i.HasValue);    // False
i = 5;
Console.WriteLine(i.HasValue);    // True
Console.WriteLine(i.Value);       // 5

변환 규칙

  • T에서 T?로의 변환은 암시적(implicit)임
  • T?에서 T로의 변환은 명시적(explicit)임
int? x = 5;        // 암시적 변환
int y = (int)x;    // 명시적 변환 필요

// HasValue가 false일 때 Value 프로퍼티 접근 시 예외 발생
int? z = null;
int v = (int)z;    // InvalidOperationException 발생

박싱과 언박싱

  • Nullable<T>를 object로 변환할 때만 박싱이 발생함
  • 이때 T? 값이 아닌 내부의 T 값만 박싱됨 (최적화)
  • null 값은 null 참조로 박싱됨
int? n = 5;
object obj = n;    // 5만 박싱됨
Console.WriteLine(obj.GetType());    // System.Int32

int? n2 = null;
object obj2 = n2;    // null 참조가 됨
Console.WriteLine(obj2 == null);    // True

// as 연산자로 nullable 타입의 언박싱이 가능
object o = "string";
int? x = o as int?;
Console.WriteLine(x.HasValue);    // False

연산자 리프팅(Operator Lifting)

  • Nullable<T> 구조체는 자체적으로 연산자를 정의하지 않음
  • 대신 기본 타입의 연산자를 "들어올려서(lift)" 사용함
  • 연산자 종류에 따라 서로 다른 null 처리 규칙이 적용됨

동등 연산자(== 와 !=)

  • null과 null을 비교하면 true를 반환
  • null과 non-null을 비교하면 false를 반환
  • non-null 값끼리는 실제 값을 비교
int? x = 5;
int? y = null;
int? z = null;

Console.WriteLine(x == null);   // False
Console.WriteLine(y == null);   // True
Console.WriteLine(y == z);      // True (null == null)
Console.WriteLine(x == 5);      // True

비교 연산자(<, >, <=, >=)

  • null을 포함한 비교는 항상 false를 반환
  • 이는 "알 수 없는 값은 비교할 수 없다"는 개념을 반영
int? a = 5;
int? b = null;

Console.WriteLine(a > 3);      // True
Console.WriteLine(b > 3);      // False
Console.WriteLine(b <= 3);     // False

산술 연산자(+, -, *, /, %, &, |, ^, <<, >>, +, ++, --, !, ~)

  • 피연산자 중 하나라도 null이면 결과도 null
  • SQL의 null 처리 방식과 유사함
int? x = 5;
int? y = null;

Console.WriteLine(x + 2);       // 7
Console.WriteLine(x + y);       // null
Console.WriteLine(y * 10);      // null
Console.WriteLine(++x);         // 6
Console.WriteLine(++y);         // null

Nullable bool과 논리 연산자

  • null은 "알 수 없는 값"으로 처리됨
  • 논리표에 따라 결과가 확정되는 경우가 있음
bool? n = null;
bool? t = true;
bool? f = false;

Console.WriteLine(n | t);    // True  (알 수 없는 값 OR true = true)
Console.WriteLine(n & f);    // False (알 수 없는 값 AND false = false)
Console.WriteLine(n | f);    // null  (알 수 없는 값 OR false = 알 수 없음)
Console.WriteLine(n & t);    // null  (알 수 없는 값 AND true = 알 수 없음)

bool? 타입의 특수한 & 및 | 연산

  • bool? 타입은 SQL과 유사한 3가지 상태 논리를 구현함
  • & 및 | 연산자는 null을 알 수 없는 값으로 처리함
bool? n = null;
bool? f = false;
bool? t = true;

Console.WriteLine(n | n);     // null
Console.WriteLine(n | f);     // null
Console.WriteLine(n | t);     // True
Console.WriteLine(n & n);     // null
Console.WriteLine(n & f);     // False
Console.WriteLine(n & t);     // null

실제 활용 시나리오

  • 데이터베이스의 nullable 컬럼과 매핑할 때 유용함
  • 존재하지 않는 값을 명확하게 표현할 수 있음
public class Customer
{
    public decimal? AccountBalance;    // null은 잔액 정보 없음을 의미
}

  • Null 병합 연산자(??)와 함께 사용하면 효과적임
int? x = null;
int y = x ?? 5;    // x가 null이면 5를 사용
Console.WriteLine(y);    // 5

int? a = null, b = 1, c = 2;
Console.WriteLine(a ?? b ?? c);    // 1 (첫 번째 non-null 값)

  • GetValueOrDefault() 메서드 사용 예시
    • 만약 x의 값이 들어있다면 x값 리턴, 아니면 default값을 내가 넘긴 값으로 리턴해랴 라는 메서드다.
int? x = null;
int y = x.GetValueOrDefault();      // 0 (int의 기본값)
int z = x.GetValueOrDefault(123);   // 123 (지정한 기본값)

Nullable Value Types의 대안들

매직값(Magic Value) 패턴의 문제점

  • 특정 값을 null 대신 사용하는 패턴
  • 코딩에서 magic이 들어가는 형식은 되도록 피해야한다.
    • 긍정적을 보여지지 않는다.
  • 예: String.IndexOf는 찾지 못했을 때 -1을 반환함
int i = "Pink".IndexOf('b');
Console.WriteLine(i); // -1

  • IndexOf : 인덱스를 반환해야하지만 인덱스가 없기에 임의로 지정한 값이 반환된다.

매직값 패턴의 단점

  • 각 타입마다 다른 null 표현 방식을 사용해야 함
  • Nullable Value Types는 모든 값 타입에 동일한 패턴 제공
  • 적절한 매직값이 없는 경우가 있음 (예: decimal 잔고)
  • 매직값 검사를 빼먹으면 잘못된 값이 조용히 전파될 수 있음
  • 타입 시스템에서 null 가능성이 표현되지 않음
    • 컴파일러의 타입 체크 지원을 받을 수 없음

확장 메서드

개념과 기본 사용법

  • 익스텐션 메서드는 기존 타입의 정의를 변경하지 않고 새로운 메서드를 추가할 수 있게 해주는 기능임
  • 주요 특징:
    • 정적(static) 클래스 내에 정적 메서드로 정의함
    • 첫 번째 매개변수에 this 수식어를 붙임
    • 첫 번째 매개변수의 타입이 확장하려는 대상 타입임
    • 굳이 수정해야겠다면 ‘상속’을 받고 재정의를 하는게 좋다.
      • 어쩔 수 없는 경우 확장 메서드를 사용한다.
  • 기본 예제:
// 사용 예시
Console.WriteLine("Perth".IsCapitalized());  // True

public static class StringHelper
{
    public static bool IsCapitalized(this string s)
    {
        if (string.IsNullOrEmpty(s)) return false;
        return char.IsUpper(s[0]);
    }
}

  • static 메서드의 첫번째 매개변수에 this 키워드가 온다면 해당 데이터형의 호출자가 매개변수로 온다고 알리는 것이다.
    • 즉, 리터럴에서 호출하게 되면 해당 리터럴이 매개변수가 되는것이다.
    • this가 첫번째 매개변수에 있다면 ‘확장 메서드’가 되는것이다.
      • 없다면 클래스명.메서드명 으로 호출해야한다.

컴파일러의 변환 과정

  • 컴파일러는 익스텐션 메서드 호출을 일반 정적 메서드 호출로 변환함
  • 예시:
// 익스텐션 메서드 호출
"Perth".IsCapitalized();

// 컴파일러가 변환한 형태
StringHelper.IsCapitalized("Perth");

메서드 체이닝(Method Chaining)

  • 익스텐션 메서드는 메서드 체이닝을 구현하는데 유용함
  • 즉, 메서드를 호출하면서 또 메서드를 호출하는 것이다.
    • 반환형과 인자가 같을때 사용 가능하다.
  • 예제:
public static class StringHelper
{
    public static string Pluralize(this string s) => s + "s";  // 단순화를 위한 예시
    public static string Capitalize(this string s) =>
        string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s.Substring(1); //substring : 문자열의 일부를 떼어 반환하는 것이다. / 1이면 문자열의 첫번쨰 문자 빼고 끝까지의 문자열을 반환하게 된다.
}

// 메서드 체이닝 사용
string result = "sausage".Pluralize().Capitalize();
Console.WriteLine(result);  // 출력: Sausages

// 위 코드는 다음과 동일함
string result2 = StringHelper.Capitalize(StringHelper.Pluralize("sausage"));
Console.WriteLine(result2); // 출력: Sausages

  • substring : 문자열의 일부를 떼어 반환하는 것이다.
    • 1이면 문자열의 첫번쨰 문자 빼고 끝까지의 문자열을 반환하게 된다.

모호성과 해결 방법

네임스페이스 범위

  • 익스텐션 메서드는 해당 정적 클래스의 네임스페이스가 using 지시문으로 포함된 경우에만 사용 가능함
using System;

namespace Utils
{
    public static class StringHelper
    {
        public static bool IsCapitalized(this string s)
        {
            if (string.IsNullOrEmpty(s)) return false;
            return char.IsUpper(s[0]);
        }
    }
}

namespace MyApp
{
    using Utils;  // 이 using 지시문이 필요함

    class Test
    {
        static void Main()
        {
            Console.WriteLine("Perth".IsCapitalized());  // True
        }
    }
}

인스턴스 메서드와의 관계

  • 동일한 시그니처를 가진 인스턴스 메서드가 있는 경우, 항상 인스턴스 메서드가 우선
class Test
{
    public void Foo(object x) { }  // 인스턴스 메서드
}

static class Extensions
{
    public static void Foo(this Test t, int x) { }  // 익스텐션 메서드
}

// 사용 예시
Test t = new Test();
t.Foo(1);  // Test 클래스의 인스턴스 메서드가 호출됨

  • 익스텐션 메서드를 명시적으로 호출하려면 정적 메서드 구문을 사용해야 함:
Extensions.Foo(t, 1);  // 익스텐션 메서드를 명시적으로 호출

익스텐션 메서드 간의 모호성

  • 동일한 시그니처를 가진 두 익스텐션 메서드가 있는 경우, 더 구체적인 타입의 메서드가 우선
    • 즉, 부모클래스보다 자식클래스의 데이터형의 메서드가 먼저 호출된다.
static class StringHelper
{
    public static bool IsCapitalized(this string s) { ... }
}

static class ObjectHelper
{
    public static bool IsCapitalized(this object s) { ... }
}

// string은 object보다 더 구체적인 타입이므로 StringHelper의 메서드가 호출됨
bool test = "Perth".IsCapitalized();