300

C# 이란 무엇인가?

  • MS에서 개발한 객체 지향 프로그래밍 언어
  • .NET 프레임 워크와 함께 도입되었으며, 안전하고 현대적인 소프트웨어 개발을 위해 설계
  • JAVA C++과 유사한 문법 구조를 가짐

struct(구조체) 와 class 의 주요 차이점

  • struct는 값 타입으로 stack에 저장, class는 참조 타입으로 heap에 저장
  • Value Type : struct, enum, int, float, double, bool, char
  • Reference Type : class, interface, delegate, string, array
  • struct는 상속 지원을 하지 않음, 하지만 class 는 상속을 지원
  • struct는 매개변수 없는 생성자를 가질 수 없는데 반해, class는 매개변수 없는 생성자를 가질 수 있음

char Type / string Type

정의

char
  • 16bit 유니코드 문자를 나타내는 값
  • 단일 문자만을 나타낼 수 있음
    string
  • 문자의 시퀀스를 나타내는 참조 타입
  • 여러문자로 구성된 텍스트를 나타낼 때 사용

    리터럴 표현

    char
  • 작은 따옴표 (')를 사용하여 리터럴을 정의
  • ‘A’, ‘1’, ‘%’
    string
  • 큰 따옴표 (")를 사용하여 리터럴을 정의
  • “Hello”, “C#”

    길이

  • char : 항상 길이가 1
  • string : 0 에서 여러 개의 문자로 구성 됨

    불변성

  • char : 값 타입으로 한 번 할당되면 변경할 수 없다
  • string : 불변 => 문자열을 수정하며 새로운 인스턴스가 생성됨

    사용영역

    char
  • 개별 문자를 처리하거나 분석할 때 사용
  • 문자가 숫자인지 알파벳인지 확인할 때 사용될 수 있음
    string
  • 텍스트 데이터를 저장하고, 저장할 때 사용
  • 사용자의 이름, 주소, 메시지 등을 저장하거나 출력할 때 사용

    Value Type과 Reference Type이 다른 영역을 사용하는 이유

    메모리 할당 및 해제의 효율성

    스택 (Stack)
  • 스택은 LIFO(Last-In-First-Out) 방식으로 동작하는 데이터 구조입니다.
  • 값 타입은 스택에 저장되는데, 이는 함수나 메서드의 호출이 끝나면 자동으로 그 범위(scope) 내의 로컬 변수들이 메모리에서 제거되기 때문입니다.
  • 이러한 자동 메모리 관리는 매우 효율적입니다.
    힙 (Heap)
  • 참조 타입은 힙에 저장되는데, 힙은 메모리의 동적 할당 및 해제를 위한 영역입니다.
  • 객체의 생명 주기는 코드의 특정 범위나 함수의 실행 시간과 연관되지 않습니다.
  • 따라서, 가비지 컬렉터가 필요하게 되어 이 영역의 메모리를 관리합니다.

    메모리 접근의 속도

  • 스택 메모리에 접근하는 것은 힙 메모리에 접근하는 것보다 일반적으로 빠릅니다.
  • 값 타입이 대체로 작고, 고정된 크기를 가지므로 스택에서의 할당과 접근이 빠르고 예측 가능하게 됩니다.

    생명 주기 관리

  • 참조 타입은 사용되지 않을 때 가비지 컬렉터에 의해 메모리에서 제거됩니다.
  • 이는 참조 타입의 생명 주기가 더 복잡하기 때문입니다.
  • 반면, 값 타입의 생명 주기는 해당 변수의 범위와 연관되어 있어 별도의 관리가 필요하지 않습니다.

    값의 할당 및 복사

  • 값 타입은 변수에 할당될 때 값 자체가 복사됩니다.
  • 이러한 특성은 스택의 구조와 잘 맞습니다.
  • 반면, 참조 타입은 참조(주소)가 복사되므로 실제 객체의 데이터를 관리하기 위해 힙을 사용합니다.

.NET에서는 값 타입과 참조 타입을 각기 다른 메모리 영역에 저장하도록 설계되었습니다. 이러한 분리는 메모리 관리 및 성능 최적화의 측면에서 중요한 역할을 합니다.

Interface의 역할

  • interface는 메서드, 속성, 이벤트, 인덱서 등의 선언만 포함할 수 있으며 구현을 포함할 수 없음
  • class나 구조체는 여러 인터페이스는 동시에 구현할 수 있음
  • interface는 다중 상속의 문제점을 해결하면서, 여러 가지 기능을 한 class에 추가할 수 있게 도와줍니다.

Garbage Collection 의 역할

  • .NET 프레임워크의 일부로, 메모리 관리를 자동화 함
  • 더 이상 사용되지 않는 객체를 자동으로 감지하고, 그 메모리를 회수
  • 메모리 누수 문제를 피할 수 있음

세대별 GC

0세대 : GC를 한번도 겪지 않은 갓 생성된 객체가 대상 1세대 : GC를 1회 겪은 객체가 대상
2세대 : GC를 2회 이상 겪은 객체가 대상(전체를 의미)`

  • 세대가 낮은 메모리부터 메모리 해제를 해준 다음 메모리 컴펙션을 해준다.
  • 2세대 GC를 할 시 Full Garbage Collection이라 하고 전체 Heap에 대하여 GC하는 것을 의미한다.
  • 세대를 나누는 근거
    최근에 생성된 객체일수록 생명주기가 짧을 가능성이높고, 오래된 객체일수록 생명주기가 길 가능성이 높습니다. 최근에 생성된 객체끼리는 서로 연관성이 높을 수 있으며, 비슷한 시점에 자주 액세스 됩니다.
    일부분 Heap에 대해 GC를 하는 것이 전체 GC를 하는 것 보다 빠릅니다.
LOH
  • Large Object Heap으로 CLR(Common Language Runtime)에서 용량이 큰(83KB이상) 객체에 사용되는 Heap
    SOH
  • 그 이하 평소에 사용되는 Heap
  • LOH는 GC시 2세대로 간주하고, 메모리 해제 후 메모리 컴펙션을 진행하지 않으므로, 메모리 내부 단편화가 발생할 수 있다.

컴팩션 (Compaction)

데이터베이스 컴팩션
  • 데이터베이스에서 레코드가 삭제되거나 변경될 때, 해당 데이터베이스 파일에는 여전히 사용되지 않는 공간 (또는 “프래그먼트”)이 남을 수 있음
  • 컴팩션은 이러한 프래그먼트를 제거하고 데이터베이스 파일의 크기를 줄이는 과정
힙 메모리 컴팩션 :
  • 가비지 컬렉션을 사용하는 프로그래밍 언어에서, 객체가 메모리에서 해제될 때 메모리의 힙 영역에는 사용되지 않는 공간이 남을 수 있음
  • 컴팩션은 이러한 공간을 재구성하여 연속적인 메모리 영역을 만드는 과정입니다.

컴팩션은 시스템의 성능을 향상시키고, 사용되지 않는 리소스를 효율적으로 회수하는 데 중요한 역할을 합니다.

Acync / Await의 용도

  • C# 에서 비동기 프로그래밍을 지원하기 위해 도입
  • async는 비동기적으로 실행될 수 있음을 나타냄
  • await는 비동기 작업의 완료를 기다림
  • 이를 통해 UI 스레드의 차단을 피하거나 백그라운드 작업을 쉽게 수행할 수 있음

delegate / event 의 차이

delegate
  • delegate는 ‘타입’ 으로, 특히 참조 타입
  • 특정 시그니처를 갖는 메서드를 참조하는 타입
  • delegate는 메서드의 참조를 보관하며, 이를 사용하여 메서드를 호출할 수 있습니다.
  • 여러 메소드를 참조할 수 있음 (다중 캐스팅)
    event
  • event는 delegate 타입의 변수에 특별한 접근 제한을 부여
  • 클래스나 구조체 외부에서 직접 접근은 불가
  • event는 구독자가 추가되거나 제거될 때만 접근 가능 (+=. -=)

IEnumerable / IQueryable의 차이

정의 및 사용처

IEnumerable
  • System.Collection 네임스페이스에 정의, 메모리 내 컬렉션의 데이터를 반복하는 기능을 제공
    IQueryable
  • System.Linq 네임스페이스에 정의, 데이터베이스와 같은 원격 데이터 소스에 대한 쿼리를 평가하고 최적화하는 기능을 제공

작동 방식

IEnumerable
  • LINQ to Objects 쿼리는 메모리에서 실행됩니다. 쿼리는 모든 데이터를 메모리로 가져온 후 처리
    IQueryable
  • 쿼리가 데이터베이스에 최적화된 형태 (예: SQL)로 변환되어 데이터베이스에서 실행됩니다. 이로 인해 필요한 데이터만 가져오게 됨

지연 로딩 (Lazy Loading)

IEnumerable
  • 데이터베이스에서 데이터를 가져온 후 필터링을 수행
    IQueryable
  • 필터링이 데이터베이스에서 수행되므로 필요한 데이터만 가져옴

확장 메서드

IEnumerable
  • System.Linq.Enumerable 클래스에 정의된 메서드에 대한 확장 메서드를 사용
    IQueryable
  • System.Linq.Queryable 클래스에 정의된 메서드에 대한 확장 메서드를 사용

    실행 위치

    IEnumerable
  • 클라이언트 측에서 실행
    IQueryable
  • 서버 측 (예: 데이터베이스 서버)에서 실행

    적합한 시나리오

    IEnumerable
  • 메모리 내의 작은 데이터 세트를 처리할 때 적합
    IQueryable
  • 큰 데이터베이스나 원격 데이터 소스를 사용할 때 적합합니다. 쿼리가 서버 측에서 평가되므로 성능상의 이점

요약하면, IEnumerable은 메모리 내 데이터의 반복에 중점을 둔 반면, IQueryable은 원격 데이터 소스에 대한 쿼리를 최적화하는 데 중점을 둡니다.

sealed의 용도

  • 더 이상 해당 클래스가 상속 될 수 없도록 봉인
  • 상속제한, 버전관리, 성능최적화, 클래스 의도 명확화

ref / out / in 키워드의 차이점

ref
  • 매개변수를 참조로 전달하는데 사용
  • 매개변수를 사용할 때 해당 변수는 반드시 초기화 되어 있어야 함
  • 호출 시 매개변수 앞에도 ref 키워드를 사용해야 함
    out
  • 메서드의 값을 반환하도록 의도된 매개변수에 사용
  • 초기값 제공 불필요
  • 매개변수는 메서드 내에서 반드시 값을 할당 받아야 함
  • 호출 시 out 키워드 사용해야 함
    in
  • 7.2 부터 도입
  • 읽기전용 참조로 전달 하는데 사용
  • 메서드 내에서 매개변수 값을 수정할 수 없음
  • 호출시 키워드 사용은 선택사항

C# 8.0에서 소개된 nullable reference types

  • C# 8.0에서는 Nullable Reference Types라는 기능을 도입하여 null 관련 버그를 줄이기 위한 강력한 도구를 제공합니다.
  • 전통적으로, C#에서의 참조 타입은 null이 될 수 있었고, 이것은 종종 예기치 않은 NullReferenceException의 원인이 되었습니다.
  • Nullable Reference Types를 도입함으로써, 개발자는 명시적으로 어떤 참조 타입이 null이 될 수 있는지, 아닌지를 지정할 수 있게 되었습니다.

C#의 메모리 관리 방식

Stack 과 Heap

Stack
  • 지역 변수와 메서드 호출 정보와 같은 임시 데이터를 저장하는 메모리 영역
  • 메서드가 종료되면 해당 stack frame도 제거 됨
    Heap
  • 동적으로 할당 되는 객체 (new 객체)를 저장하는 메모리 영역
  • 힙에 있는 객체는 GC에 의해 관리 됨

GC (가비지컬렉션)

  • .NET에서 자동으로 동작하는 메모리 관리자
  • heap 메모리에서 더이상 접근 되지 않는 객체를 식별하고 해제하여 재사용가능하도록 함
  • Generation 개념을 사용하여 메모리를 효율적으로 관리 처음 생성된 객체는 0에 위치, 이루 실행될 때마다 살아남는 객체는 다음 세대로 이동

Finalization

  • 객체가 메모리에서 해제되기 전에 어떤 마지막 작업을 수행해야 할 경우 정의함
  • ’~’ 기호를 사용하여 클래스내에 정의
  • Finalizer가 있는 객체는 메모리 해제가 두 번의 GC 사이클이 필요하기 때문에 성능에 영향을 줄 수 있음

IDisposable 패턴

  • 인터페이스로 객체가 사용하는 비관리 리소스 (파일핸들, 네트워크 소켓, 데이터베이스 연결)를 명시적으로 해제할 수 있도록 해줌
  • Dispose 메서드를 구현하여 리소스를 해제하며, using 문을 사용하여 자동으로 Dispose를 호출할 수 있음

비관리 리소스

  • C#과 .NET은 기본적으로 메모리 관리를 자동화 하지만, 외부 라이브러리나 OS리소스와 같은 비관리 리소그는 직접 관리 해야함
  • IDisposable 패턴을 통해 제거 되어야 함

finalizer와 destructor의 차이점

  • C#에서 “finalizer”와 “destructor”는 종종 혼용되어 사용되는 용어
  • 사실, C#에서는 이 두 용어가 같은 개념을 나타내며, 객체가 가비지 컬렉션에 의해 수집되기 전에 실행되는 특별한 메서드를 참조
  • 그러나 두 용어 간에는 문맥적인 차이점이 있음
    Destructor
  • C++에서 오는 용어
  • C++에서 소멸자 (destructor)는 객체가 소멸될 때 자동으로 호출되는 메서드
  • C#에서는 ~ 기호를 사용하여 destructor를 정의 - 예: ~MyClass() { ... }
  • C#에서의 destructor는 내부적으로 finalizer로 구현
Finalizer
  • .NET과 C#의 문맥에서 사용되는 용어
  • 객체가 가비지 컬렉션에 의해 수집되기 전에 실행되는 코드를 정의
  • C#에서 destructor를 작성하면 컴파일러는 그것을 finalizer로 변환하며, 내부적으로 Finalize 메서드를 오버라이드

C#에서의 오버로딩과 오버라이딩의 차이

오버로딩 (Overloading)
  • 오버로딩은 하나의 클래스 내에서 같은 이름의 메서드를 여러 번 정의하는 것을 의미
  • 매개변수의 유형, 개수, 순서가 다르게 정의되어야 함
  • 반환 타입만 다른 경우에는 오버로딩으로 간주되지 않음
  • 오버로딩의 주 목적은 동일한 작업을 수행하지만 다른 매개변수 유형이나 개수를 가진 메서드를 제공하는 것
public class MathOperations
{
    public int Add(int a, int b)
    {
        return a + b;
    }
    
    public double Add(double a, double b)
    {
        return a + b;
    }
}
오버라이딩 (Overriding)
  • 파생 클래스에서 기반 클래스의 메서드를 재정의하는 프로세스를 의미
  • 기반 클래스의 메서드가 virtual, abstract 또는 override 키워드로 선언되어야 함
  • 파생 클래스에서 해당 메서드를 재정의할 때 override 키워드를 사용
  • 오버라이딩의 주 목적은 파생 클래스에서 기반 클래스의 행동을 수정하거나 확장하는 것
public class Animal
{
    public virtual void Speak()
    {
        Console.WriteLine("The animal speaks.");
    }
}

public class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("The dog barks.");
    }
}

오버로딩은 같은 이름의 메서드를 다양한 매개변수로 제공하는 것이며, 오버라이딩은 파생 클래스에서 기반 클래스의 메서드 행동을 변경하는 것

C#에서 dynamic 키워드의 용도와 장단점

  • C#에서 dynamic 키워드는 .NET 4.0부터 도입
  • 런타임에 데이터 타입을 결정하는 기능을 제공
  • 컴파일 타임에 변수의 타입이 결정되지 않지만, 런타임에 그 타입을 확인하고 해당 타입의 모든 작업을 수행

    용도

    런타임에 타입 결정
  • dynamic 변수의 실제 타입은 런타임에 결정되므로, 컴파일 타임에는 해당 변수에 대한 어떠한 연산도 허용
    COM 및 Reflection
  • 예전 COM 인터페이스나 리플렉션을 사용하여 코드를 작성할 때, 타입 정보가 항상 사용 가능하지 않을 수 있습니다. 이러한 경우에 dynamic을 사용하면 코드가 간결해짐
    Interop 시나리오
  • 다른 언어와의 상호 운용성 (예: Python, JavaScript)에서 dynamic이 유용할 수 있음
  • C# 코드 내에서 동적 언어의 객체를 사용하려면 dynamic 키워드를 사용할 수 있음

장점

  • 유연성 : 특정 시나리오에서 코드를 더 간결하고 읽기 쉽게 만들 수 있음
  • 간단한 Interop : 다른 동적 언어나 COM 객체와의 상호 운용성을 쉽게 만들어 줌

단점

  • 컴파일 타임 검사 누락 : dynamic을 사용하면 컴파일러의 타입 검사가 누락, 런타임 오류의 위험이 높아짐
  • 성능 오버헤드 : dynamic 변수의 작업은 런타임에 수행, 일반적인 변수에 비해 추가적인 오버헤드가 발생
  • 코드 가독성 : 너무 많은 dynamic 변수를 사용하면 코드의 가독성이 떨어질 수 있으며, 코드의 흐름을 이해하기 어려워짐

결론적으로, dynamic 키워드는 특정 시나리오에서 매우 유용할 수 있지만, 가능하면 정적 타이핑을 선호하고, 꼭 필요한 경우에만 dynamic을 사용하는 것이 좋음

LINQ (Language Integrated Query)

  • LINQ는 .NET 언어에 통합된 쿼리 기능
  • LINQ는 데이터에 대한 쿼리를 쓰기 위한 일관된 문법을 제공
  • 다양한 데이터 소스 (예: 배열, 컬렉션, 데이터베이스, XML 등)에 대한 쿼리를 작성할 수 있음
  • LINQ는 SQL과 유사한 문법을 가지고 있지만, 순수 C# 또는 VB.NET 코드로 작성됨
LINQ의 장점
  • 통합된 문법 : 다양한 데이터 소스에 대한 쿼리를 작성할 때 동일한 문법을 사용할 수 있음
  • 강력한 필터링 및 정렬 : 복잡한 데이터 변환, 필터링 및 정렬 작업을 간단한 문법으로 수행할 수 있음
  • 타입 안전성 : LINQ 쿼리는 컴파일 타임에 타입 검사를 거치므로, 런타임 오류의 위험을 줄일 수 있음
  • 읽기 쉬운 코드 : LINQ는 읽기 쉬운 선언적 문법을 사용하므로, 코드의 의도가 명확하게 드러남
  • 지연 실행 : LINQ 쿼리는 실행 시점까지 지연될 수 있음, 이를 통해 효율적인 데이터 처리가 가능
LINQ의 단점
  • 성능 오버헤드 : 간단한 작업에 대해서는 전통적인 반복문이 LINQ 쿼리보다 빠를 수 있음, 따라서 성능이 중요한 경우에는 LINQ 사용 전/후를 적절히 평가해야함
  • 학습 곡선 : LINQ 문법은 다소 낯설 수 있으므로, 새로운 사용자들에게는 초기 학습 곡선이 있을 수 있음
  • 디버깅의 어려움 : 복잡한 LINQ 쿼리는 디버깅하기 어려울 수 있음, 특히 지연 실행의 경우, 예상치 못한 시점에서 오류가 발생할 수 있음

Attributes

  • 속성은 대괄호 ([]) 안에 정의되며, 대상 코드 엔터티 바로 앞에 위치
    유용한 경우
  • 문서화 : 속성을 사용하여 코드의 의도나 사용 방법에 대한 추가 정보를 제공
  • 코드 검사 : 컴파일러 또는 코드 분석 도구에 특정 코드 동작을 지시할 수 있음 예를 들어, NotNull 속성은 특정 매개변수가 null이 아니어야 함을 나타냄
  • 런타임 동작 제어 : 리플렉션을 사용하여 속성 정보를 읽고 기반으로 동작을 변경할 수 있음
  • 설정 및 구성 : 속성을 사용하여 코드에 대한 설정이나 구성 정보를 제공할 수 있음
  • 직렬화 제어 : 어떤 필드나 프로퍼티가 직렬화되거나 직렬화에서 제외되어야 하는지 지시하는 데 속성을 사용할 수 있음
  • ORM (Object-Relational Mapping)**: 데이터베이스와 객체 간의 매핑을 정의하는 데 속성을 사용할 수 있음 예를 들어, Entity Framework에서는 데이터베이스의 테이블과 열을 나타내는 클래스와 프로퍼티에 속성을 사용
  • 테스트 프레임워크 : 단위 테스트 프레임워크 (예: NUnit, MSTest)에서는 테스트 메서드와 관련된 메타데이터를 제공하기 위해 속성을 사용합니다.

partial

  • C#에서 클래스, 구조체, 인터페이스 또는 메서드의 정의를 여러 파일로 분할할 수 있게 해주는 키워드
  • partial 키워드를 사용하면 하나의 타입의 정의를 여러 파일에 걸쳐 나누어 작성할 수 있으며, 컴파일 시점에 이러한 부분들이 하나의 타입으로 결합

MyClass.Part1.cs

partial class MyClass
{
    public void Method1()
    {
        // ... implementation of Method1
    }
}

MyClass.Part2.cs

partial class MyClass
{
    public void Method2()
    {
        // ... implementation of Method2
    }
}

partial 키워드를 사용할 때 주의할 점은 모든 partial 정의가 동일한 접근 한정자를 가져야 하며, 한정자가 명시되지 않은 경우 기본적으로 private으로 간주된다는 것

객체지향

SOLID 원칙

단일 책임 원칙 (Single Responsibility Principle, SRP)
  • 클래스는 하나의 목적만 가져야 한다.
  • 클래스를 변경하는 이유는 오직 하나여야 한다.
    개방-폐쇄 원칙 (Open/Closed Principle, OCP)
  • 클래스는 확장에는 열려 있어야 하고, 기존 코드 변경에는 닫혀 있어야 한다.
    리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
  • 하위 타입은 상위 타입의 객체를 바꾸어 사용될 수 있어야 한다.
    인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
  • 클래스는 필요하지 않은 인터페이스에 의존하게 되면 안 된다.
    의존관계 역전 원칙 (Dependency Inversion Principle, DIP)
  • 구체적인 구현보다는 추상화에 의존해야 한다.

객체 지향 프로그래밍 (OOP) 원칙

캡슐화 (Encapsulation)
  • 연관된 변수와 메서드를 하나의 단위로 묶음.
  • 클래스의 정보를 외부에서 직접 접근하지 못하게 하여 정보를 은닉한다.
    추상화 (Abstraction)
  • 객체에서 공통된 부분을 식별하여 일반적인 특성을 정의한다.
    상속 (Inheritance)
  • 하위 클래스가 상위 클래스의 특성과 기능을 물려받는 것.
    다형성 (Polymorphism)
  • 동일한 메서드나 함수가 다양한 방식으로 동작하는 것.
  • 오버로딩과 오버라이딩을 통해 구현된다.

.Equals() / ==

기본 동작

==
  • 기본적으로 값 타입의 경우 값의 동등성을 비교하고, 참조 타입의 경우 두 객체의 참조(메모리 주소)가 동일한지 비교합니다.
    .Equals()
  • 메서드는 System.Object 클래스에 정의되어 있으며, 기본적으로 == 연산자와 동일한 동작을 합니다.
  • 그러나 많은 클래스들이 이 메서드를 오버라이드하여 값의 동등성을 비교하는 동작을 제공합니다.

    오버로드 및 오버라이드

    ==
  • 사용자 정의 타입에서 == 연산자의 동작을 재정의(오버로드)할 수 있습니다.
  • 이렇게 하면, 사용자 정의 타입에서도 값의 동등성을 비교하도록 만들 수 있습니다.
    .Equals()
  • 사용자 정의 타입에서 이 메서드를 오버라이드하여 원하는 동등성 비교 로직을 제공할 수 있습니다.

    다형성

    ==
  • == 연산자는 다형성을 지원하지 않습니다.
  • 즉, 부모 클래스의 참조를 통해 참조되는 자식 클래스의 객체에 대해 == 연산자를 사용하면 부모 클래스에 정의된 == 연산자의 동작이 적용됩니다.
    .Equals()
  • 이 메서드는 다형성을 지원합니다.
  • 따라서, 부모 클래스의 참조를 통해 참조되는 자식 클래스의 객체에 대해 .Equals() 메서드를 호출하면 자식 클래스에서 오버라이드된 메서드의 동작이 적용됩니다.

    null 비교

    ==
  • 두 참조가 모두 null인 경우에도 동등하다고 판단합니다.
    .Equals()
  • 호출하는 객체가 null이면 NullReferenceException이 발생합니다.
  • 따라서, .Equals()를 호출하기 전에 null 검사를 해야 합니다.