iBetter Books
수정

Dart에서 StringString?은 비슷해 보이지만, 실제로는 완전히 다른 타입입니다. 마치 "전화가 있는 사람"과 "전화가 있을 수도 없을 수도 있는 사람"이 다른 것처럼요. 이 챕터에서는 Dart의 타입 계층 구조를 들여다보고, 두 세계를 오가는 방법을 익힙니다.

Dart의 타입 계층 구조

Dart의 모든 타입은 계층적으로 연결되어 있습니다. 그 최상위에는 Object?가 있습니다.

Object?            ← 모든 타입의 조상 (nullable 포함)
├── Object         ← non-nullable 타입의 조상
│   ├── num
│   │   ├── int
│   │   └── double
│   ├── String
│   ├── bool
│   ├── List
│   └── ... (모든 클래스)
├── Null           ← null 값의 타입
└── Never          ← 아무것도 아닌 타입 (계층의 가장 아래)

중요한 사실 두 가지를 짚고 넘어갑니다.

첫째, null의 타입은 Null(대문자)입니다. null 리터럴은 Null 타입의 유일한 인스턴스입니다.

둘째, String?은 실제로 String | Null의 줄임말입니다. 즉, String이거나 Null인 타입입니다. 그래서 String? 변수에는 문자열도 들어갈 수 있고 null도 들어갈 수 있는 것입니다.

void main() {
  // null의 타입은 Null입니다
  Null n = null;      // Null 타입은 null 값만 가질 수 있습니다
  print(n);           // null

  // String?은 String 또는 Null입니다
  String? name = null;   // Null이 할당됨
  name = 'Dart';         // String이 할당됨

  // Object는 non-nullable의 최상위입니다
  Object obj = 'hello';  // String은 Object입니다
  Object obj2 = 42;      // int도 Object입니다
  // Object obj3 = null; // 컴파일 오류 — Object는 null 불가입니다

  // Object?는 nullable 포함 모든 것의 최상위입니다
  Object? anything = null;       // OK
  anything = 'text';             // OK
  anything = 42;                 // OK
}

String vs String? — 완전히 다른 타입

StringString?은 부모-자식 관계가 아닙니다. StringString?의 서브타입입니다. String은 항상 String?에 대입할 수 있지만, String?은 String에 직접 대입할 수 없습니다.

void main() {
  String nonNull = 'hello';
  String? nullable = null;

  // String → String? 대입 가능합니다 (서브타입이므로)
  String? a = nonNull;  // OK

  // String? → String 대입 불가합니다 (null일 수 있으므로)
  // String b = nullable;  // 컴파일 오류

  // 타입도 다릅니다
  print(nonNull.runtimeType);   // String
  print(nullable.runtimeType);  // Null (현재 null이므로)
  nullable = 'world';
  print(nullable.runtimeType);  // String (값이 있으므로)
}

함수의 매개변수에서도 이 차이는 뚜렷하게 나타납니다.

// non-nullable 매개변수 — null을 절대 받지 않습니다
void printLength(String text) {
  print(text.length);  // null 체크 없이 바로 사용합니다
}

// nullable 매개변수 — null을 받을 수 있습니다
void printLengthSafe(String? text) {
  // null 체크 없이 length를 호출하면 컴파일 오류입니다
  // print(text.length);  // 오류!

  if (text != null) {
    print(text.length);
  }
}

void main() {
  printLength('hello');        // OK
  // printLength(null);        // 컴파일 오류

  printLengthSafe('hello');   // OK
  printLengthSafe(null);      // OK
}

Never 타입 — 절대 반환하지 않는다

Never는 Dart 타입 계층에서 가장 아래에 있는 특별한 타입입니다. Never 타입의 값은 존재하지 않습니다. 그렇다면 이것은 언제 쓸까요.

함수가 절대 정상적으로 반환하지 않을 때 사용합니다. 예외를 던지거나, 무한 루프를 돌거나, 프로그램을 종료하는 함수가 해당됩니다.

// 항상 예외를 던지는 함수 — 절대 반환하지 않습니다
Never throwError(String message) {
  throw Exception(message);
}

// 프로그램을 종료하는 함수 — 절대 반환하지 않습니다
Never exitWithCode(int code) {
  throw StateError('Exit with code $code');
}

void main() {
  String? value = null;

  // Never 타입 함수를 호출하면 이후 코드는 도달 불가로 처리됩니다
  String result = value ?? throwError('값이 없습니다');
  print(result);  // value가 null이면 예외 발생, 여기까지 오지 않습니다
}

Never의 중요한 특성이 있습니다. Never는 모든 타입의 서브타입입니다. 그래서 Never를 반환하는 표현식은 어떤 타입이 기대되는 자리에도 들어갈 수 있습니다. 위 예제에서 throwError()String 자리에 사용될 수 있는 이유입니다.

// Never는 어떤 타입으로도 사용될 수 있습니다
int foo() => throw Exception();      // Never → int 대입 OK
String bar() => throw Exception();   // Never → String 대입 OK

Null 타입 — null 그 자체

Null(대문자)은 null 값의 타입입니다. Null 타입 변수는 오직 null 값만 가질 수 있습니다. 직접 Null 타입을 선언할 일은 거의 없지만, 타입 시스템을 이해하는 데 중요합니다.

void main() {
  // Null 타입은 오직 null 값만 가질 수 있습니다
  Null n = null;
  // n = 'hello';  // 컴파일 오류 — Null은 null만 가질 수 있습니다

  // String?의 내부는 String | Null입니다
  String? s = null;   // Null 부분
  s = 'hello';        // String 부분
}

nullable을 non-nullable로 변환하는 방법

String? 타입을 String 타입으로 바꾸는 방법은 여러 가지입니다. 상황에 따라 적절한 방법을 선택합니다.

if 문으로 null 체크하기

가장 안전하고 명확한 방법입니다. Dart의 흐름 분석이 작동하여 if 블록 안에서 타입이 자동으로 승격됩니다.

void main() {
  String? name = '다트';

  if (name != null) {
    // 이 블록 안에서 name은 String 타입으로 취급됩니다
    String upper = name.toUpperCase();  // null 체크 없이 사용합니다
    print(upper);  // 다트
  }
}

?? 연산자로 기본값 제공하기

null인 경우 대체할 기본값이 있다면 ??를 사용합니다.

void main() {
  String? nickname = null;

  // nickname이 null이면 '익명'을 사용합니다
  String displayName = nickname ?? '익명';
  print(displayName);  // 익명

  nickname = '다트마스터';
  displayName = nickname ?? '익명';
  print(displayName);  // 다트마스터
}

조기 반환으로 null을 걸러내기

함수 초반에 null을 체크하고 일찍 반환하면, 이후 코드에서 null을 신경 쓰지 않아도 됩니다.

void processName(String? name) {
  if (name == null) {
    print('이름이 없습니다');
    return;   // 여기서 함수를 끝냅니다
  }

  // 이 아래에서 name은 String으로 취급됩니다
  print('안녕하세요, ${name.toUpperCase()}!');
}

void main() {
  processName(null);    // 이름이 없습니다
  processName('다트');  // 안녕하세요, 다트!
}

! 연산자로 강제 언래핑하기

절대 null이 아님을 확신할 때 !를 사용합니다. 하지만 실제로 null이면 런타임 오류가 발생합니다. 신중하게 사용해야 합니다.

void main() {
  String? value = '확실히 값이 있음';

  // null이 아님을 확신하므로 ! 사용합니다
  String certain = value!;
  print(certain.length);  // 10

  // 위험한 사용 — null인데 ! 사용 시 오류 발생합니다
  String? empty = null;
  // String danger = empty!;  // 런타임 오류: Null check operator used on a null value
}

형변환으로 타입 지정하기

as 연산자를 사용할 수 있지만, 잘못된 타입이면 역시 런타임 오류가 발생합니다. !와 마찬가지로 신중하게 사용합니다.

void main() {
  Object? obj = '문자열입니다';

  // Object?를 String으로 변환합니다
  if (obj is String) {
    // is 체크 이후 타입이 자동 승격됩니다
    print(obj.length);  // 7
  }

  // as로 강제 변환합니다 — 타입이 맞지 않으면 오류 발생합니다
  String str = obj as String;  // obj가 String이 맞으므로 OK
  print(str.toUpperCase());    // 문자열입니다
}

어떤 방법을 선택해야 할까

상황에 따라 가이드라인이 있습니다.

상황 권장 방법
기본값이 있다 ?? 연산자
null이면 일찍 종료한다 조기 반환 패턴
null 여부에 따라 다른 처리가 필요하다 if (x != null)
100% 확신하고 성능이 중요하다 ! 연산자 (주의!)
타입 확인 후 변환이 필요하다 is 체크

! 연산자는 최후의 수단으로 생각하는 것이 좋습니다. 대부분의 경우 ??나 null 체크로 더 안전하게 처리할 수 있습니다.

다음 챕터에서는 nullable 타입을 안전하게 다루는 연산자들을 집중적으로 살펴봅니다. ?., ??, ??=, !의 정확한 동작 방식과 적절한 사용 시점을 배웁니다.