iBetter Books
수정

nullable 변수를 다루다 보면 같은 패턴이 반복됩니다. "이 값이 null이면 건너뛰고, null이 아니면 무언가를 한다." Dart는 이런 반복을 줄이기 위해 네 가지 "널 인지 연산자(null-aware operator)"를 제공합니다. PART 02에서 간략히 소개한 이 연산자들을 이번 챕터에서 제대로 파고듭니다.

?. — null-aware 접근 연산자

?.는 왼쪽 값이 null이면 null을 반환하고, null이 아니면 오른쪽 메서드나 속성에 접근합니다.

void main() {
  String? name = null;

  // name이 null이므로 .length를 호출하지 않고 null을 반환합니다
  int? len = name?.length;
  print(len);  // null

  name = 'Dart';
  len = name?.length;
  print(len);  // 4
}

?.의 진짜 힘은 연쇄 호출(chaining)에서 나옵니다. 중간에 하나라도 null이면 전체 표현식이 null이 됩니다.

class Address {
  String? city;
  Address({this.city});
}

class User {
  String name;
  Address? address;
  User({required this.name, this.address});
}

void main() {
  User user1 = User(
    name: '김다트',
    address: Address(city: '서울'),
  );

  User user2 = User(
    name: '이플러터',
    address: null,  // 주소 없음
  );

  User user3 = User(
    name: '박코딩',
    address: Address(city: null),  // 주소는 있지만 도시 없음
  );

  // 연쇄 ?. — 중간에 null이 있으면 전체가 null이 됩니다
  print(user1.address?.city);  // 서울
  print(user2.address?.city);  // null (address가 null)
  print(user3.address?.city);  // null (city가 null)
}

?. 없이 같은 코드를 작성하면 얼마나 번거로울지 상상해보세요. null 체크가 중첩되고, 코드가 복잡해집니다.

// ?. 없이 작성하면 이렇게 됩니다
String? getCity(User user) {
  if (user.address != null) {
    if (user.address!.city != null) {
      return user.address!.city;
    }
  }
  return null;
}

// ?.로 작성하면 한 줄입니다
String? getCity2(User user) => user.address?.city;

?. 사용 시 주의사항

반환 타입이 nullable이 된다는 점에 주의합니다. nameString?이면 name?.length의 타입은 int?입니다. int가 아닙니다.

void main() {
  String? name = 'Dart';

  int? len = name?.length;   // int? 타입입니다
  // int len2 = name?.length; // 컴파일 오류 — int?를 int에 담을 수 없습니다

  // ?? 와 함께 사용하면 기본값을 제공할 수 있습니다
  int len3 = name?.length ?? 0;  // int 타입입니다
  print(len3);  // 4
}

?? — if-null 연산자

??는 왼쪽 값이 null이면 오른쪽 값을 반환하고, null이 아니면 왼쪽 값을 그대로 반환합니다.

void main() {
  String? nickname = null;

  String display = nickname ?? '익명';
  print(display);  // 익명

  nickname = '다트러버';
  display = nickname ?? '익명';
  print(display);  // 다트러버
}

??는 연쇄로도 사용할 수 있습니다. 첫 번째가 null이면 두 번째를, 두 번째도 null이면 세 번째를 사용하는 식입니다.

void main() {
  String? primary = null;
  String? secondary = null;
  String fallback = '기본값';

  // 순서대로 non-null을 찾습니다
  String result = primary ?? secondary ?? fallback;
  print(result);  // 기본값

  secondary = '보조 값';
  result = primary ?? secondary ?? fallback;
  print(result);  // 보조 값

  primary = '주요 값';
  result = primary ?? secondary ?? fallback;
  print(result);  // 주요 값
}

?? vs 삼항 연산자

??는 null 여부만 확인합니다. 거짓(false)이나 빈 문자열은 걸러내지 않습니다. 삼항 연산자와의 차이를 명확히 이해해야 합니다.

void main() {
  String? value = '';  // 빈 문자열 — null이 아닙니다

  // ?? 는 null이 아니므로 왼쪽 값을 반환합니다
  String result1 = value ?? '기본값';
  print(result1);  // '' (빈 문자열)

  // null 또는 빈 문자열 모두 처리하려면 삼항 연산자를 씁니다
  String result2 = (value == null || value.isEmpty) ? '기본값' : value;
  print(result2);  // 기본값
}

??= — if-null 대입 연산자

??=는 변수가 null일 때만 오른쪽 값을 대입합니다. 이미 값이 있다면 아무것도 하지 않습니다.

void main() {
  String? cache = null;

  // cache가 null이므로 대입됩니다
  cache ??= '캐시된 값';
  print(cache);  // 캐시된 값

  // cache가 이미 값이 있으므로 대입되지 않습니다
  cache ??= '새로운 값';
  print(cache);  // 캐시된 값 (변경되지 않음)
}

??=는 지연 초기화 패턴에서 자주 사용됩니다. 처음 필요할 때 초기화하고, 이후에는 캐시된 값을 재사용하는 패턴입니다.

class DataService {
  List<String>? _cachedData;

  List<String> getData() {
    // 처음 호출 시에만 데이터를 로드합니다
    _cachedData ??= _loadFromDatabase();
    return _cachedData!;
  }

  List<String> _loadFromDatabase() {
    print('데이터베이스에서 로드합니다...');
    return ['항목1', '항목2', '항목3'];
  }
}

void main() {
  final service = DataService();

  print(service.getData());  // 데이터베이스에서 로드합니다... → [항목1, 항목2, 항목3]
  print(service.getData());  // 로드 메시지 없음 → [항목1, 항목2, 항목3] (캐시 사용)
}

??=는 다음과 동일합니다.

// 아래 두 코드는 같은 동작입니다
cache ??= '값';

// 위와 동일합니다
if (cache == null) {
  cache = '값';
}

! — null 단언 연산자

!는 "이 값은 null이 아니다"라고 개발자가 컴파일러에게 선언하는 연산자입니다. 컴파일러는 그 말을 믿고 통과시키지만, 실제로 null이면 런타임에 Null check operator used on a null value 오류가 발생합니다.

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

  // !로 null이 아님을 단언합니다
  String certain = name!;
  print(certain.length);  // 4

  // null인 값에 ! 사용 — 런타임 오류 발생합니다
  String? empty = null;
  // String danger = empty!;  // 런타임 오류!
}

!를 적절히 사용해야 하는 경우

!를 아예 쓰지 말라는 것이 아닙니다. 상황에 따라 !가 최선인 경우가 있습니다.

첫째, 외부 라이브러리나 JSON 파싱에서 타입 시스템이 nullable로 선언되어 있지만, 비즈니스 로직상 null이 절대 올 수 없는 경우입니다.

둘째, 테스트 코드에서 setUp에서 초기화된 변수를 사용하는 경우입니다.

셋째, 흐름 분석이 작동하지 않는 특수한 상황에서 개발자가 null이 아님을 100% 알고 있는 경우입니다.

// !가 합리적인 사용 예
Map<String, dynamic> json = {
  'name': 'Dart',
  'version': 3,
};

// json['name']의 타입은 dynamic이지만,
// JSON 스키마상 'name'은 항상 String임을 알 때
String name = json['name'] as String;
print(name.toUpperCase());  // DART

! 대신 쓸 수 있는 대안

대부분의 ! 사용은 더 안전한 방법으로 대체할 수 있습니다.

void main() {
  String? value = '안녕';

  // ! 사용 (위험)
  print(value!.length);

  // if 체크 사용 (안전)
  if (value != null) {
    print(value.length);
  }

  // ?? 사용 (안전, 기본값 제공)
  print(value?.length ?? 0);
}

네 연산자의 요약 비교

연산자 이름 동작 안전도
?. null-aware 접근 null이면 null 반환, 아니면 접근 안전
?? if-null null이면 오른쪽, 아니면 왼쪽 안전
??= if-null 대입 null일 때만 대입 안전
! null 단언 null이 아님을 강제 선언 주의 필요

좋은 Dart 코드는 !의 사용을 최소화하고, ?.??를 적극 활용합니다. !가 많이 보인다면 설계를 다시 생각해볼 필요가 있습니다.

다음 챕터에서는 late 키워드와 required를 배웁니다. 선언 시점에 초기화하지 않고 나중에 초기화하는 패턴, 그리고 named 매개변수에서 null 없이 필수값을 표현하는 방법을 익힙니다.