포스트

[Dart] Null Safety 정리

[Dart] Null Safety 정리

Dart의 Null Safety는 “값이 없을 수 있는(null) 상황”을 런타임 오류가 아니라 타입 시스템 단계에서 미리 다루도록 만든 기능이다.
즉, null로 인해 앱이 실행 중에 터지는 문제를 줄이기 위해,
컴파일 단계에서 “이 변수는 null이 될 수 있는가?”를 명확히 구분한다.
Flutter로 앱을 만들 때도 null 관련 오류는 빈번하게 나타나므로, Null Safety를 정확히 이해하면 디버깅 비용이 크게 줄어든다. 이 글에서는 Dart Null Safety의 핵심 개념과 문법, 그리고 실무적으로 자주 마주치는 패턴들을 정리한다.



1. Null Safety가 해결하려는 문제

null은 “값이 아직 정해지지 않음” 또는 “값이 존재하지 않음”을 표현할 때 쓰인다. 그러나 null은 잘못 다뤄지면 대표적인 런타임 오류인 NullPointerException(혹은 Dart에서는 null 관련 예외)로 이어질 수 있다.

Null Safety의 목적은 다음과 같이 정리할 수 있다.

  • null 가능성을 코드 작성 단계에서 명시하도록 강제한다.
  • null이 될 수 있는 값에 접근하는 위험한 코드를 컴파일 단계에서 차단한다.
  • 결과적으로 런타임 오류를 줄이고 코드의 의도를 더 분명히 만든다.


2. Non-nullable과 Nullable 타입

Dart의 Null Safety 핵심은 타입을 두 종류로 나누는 것이다.

  • Non-nullable 타입: 기본값. null이 될 수 없다.
  • Nullable 타입: ?를 붙인 타입. null이 될 수 있다.

Non-nullable 타입

1
2
int a = 10;
a = null; // 컴파일 오류

int는 기본적으로 null을 허용하지 않는다. 따라서 a = null은 컴파일 단계에서 막힌다.


Nullable 타입

1
2
int? b = 10;
b = null; // 가능

int?는 “int 또는 null”을 의미한다. 즉, b에는 null이 들어갈 수 있다.



3. Nullable 값 사용 시 필요한 처리

Nullable 타입은 null이 될 수 있으므로, 값을 사용하기 전에 안전하게 다루는 과정이 필요하다.
Dart는 이를 위해 여러 문법을 제공한다.

null 체크 (if)

1
2
3
4
5
int? x = getNullableInt();

if (x != null) {
  print(x + 1); // 여기서는 x가 int로 취급됨
}

if (x != null) 블록 내부에서는 x가 null이 아니라는 사실이 보장되므로, 컴파일러가 x를 int처럼 다룬다.


null-aware 연산자(?.)

객체가 null일 수 있을 때 안전하게 접근하려면 ?.를 사용한다.

1
2
String? name = getNullableName();
print(name?.length); // name이 null이면 null, 아니면 length

name이 null이면 전체 결과도 null이 되어 예외를 피한다.


null 병합 연산자(??)

null일 때 대체값을 주고 싶으면 ??를 사용한다.

1
2
3
String? nickname = getNullableNickname();
String display = nickname ?? "Guest";
print(display);

nickname이 null이면 "Guest"가 사용된다.


null 병합 대입(??=)

값이 null일 때만 초기화하고 싶으면 ??=가 유용하다.

1
2
String? token;
token ??= fetchToken(); // token이 null일 때만 fetchToken() 실행



4. 강제 언래핑(!)과 위험성

!는 “이 값은 절대 null이 아니다”라고 개발자가 컴파일러에 단언하는 연산자이다.

1
2
int? n = getNullableInt();
print(n! + 1);

여기서 n이 실제로 null이면 런타임에서 예외가 발생한다.
따라서 !는 다음과 같은 경우에만 제한적으로 쓰는 편이 좋다.

  • 논리적으로 null이 될 수 없다는 것이 분명하고
  • 이미 다른 경로에서 null 검증이 끝났지만
  • 코드 구조상 컴파일러가 그것을 확신하지 못하는 경우

대부분의 상황에서는 if (n != null) 같은 명시적 체크나 ??로 대체하는 편이 안전하다.



5. late 키워드: “나중에 초기화”의 선언

late는 지금은 값이 없지만, 사용하기 전에는 반드시 초기화될 것을 의미한다.
특히 Flutter에서 initState()에서 초기화하는 필드에 자주 쓰인다.

late의 기본 의미

1
2
3
4
5
6
7
8
9
late String title;

void setup() {
  title = "Hello";
}

void printTitle() {
  print(title); // setup()이 먼저 호출되어야 안전
}

late 자체는 null을 허용하지 않는다. 대신, 초기화 전에 접근하면 런타임 예외가 발생할 수 있다.
따라서 “반드시 초기화가 선행된다”는 구조가 보장될 때 사용해야 한다.


late와 lazy initialization

late final은 “처음 접근할 때 한 번만 계산”하는 용도로도 쓰인다.

1
2
3
4
5
6
7
8
9
10
11
12
late final int heavyValue = computeHeavy();

int computeHeavy() {
  print("computed");
  return 42;
}

void main() {
  print("start");
  print(heavyValue); // 여기서 computeHeavy() 실행
  print(heavyValue); // 두 번째는 재계산 없음
}


6. required와 기본값: null을 설계로 막기

Null Safety에서는 “null을 허용할 것인지”를 설계 단계에서 결정하는 것이 중요하다.
특히 함수 파라미터나 생성자에서 null 문제를 줄이려면 required와 기본값을 잘 활용하는 편이 좋다.

required 파라미터

1
2
3
4
5
6
void greet({required String name}) {
  print("Hello, $name");
}

greet(name: "Jay"); // 정상
greet(); // 컴파일 오류

required는 “반드시 전달해야 한다”는 의미이며, null을 막는 것과 함께 “사용 의도”를 명확히 한다.


기본값 제공

1
2
3
void greet({String name = "Guest"}) {
  print("Hello, $name");
}

기본값이 있으면 nullable로 만들 필요가 줄어든다.



7. 컬렉션과 Null Safety

리스트/맵에서도 null 가능성이 섞이면 타입이 달라진다.

요소가 null일 수 있는 리스트

1
List<int?> values = [1, null, 3];

리스트 자체가 null일 수 있는 경우

1
List<int>? values;

둘은 의미가 다르다.

  • List<int?>: 리스트는 항상 존재하지만 요소가 null일 수 있음
  • List<int>?: 리스트 자체가 없을 수 있음

실무에서는 이 차이를 의도적으로 선택하는 것이 중요하다.



마무리

Dart의 Null Safety는 null을 단순히 “조심해서 쓰는 값”으로 두지 않고, 타입 시스템으로 끌어올려 컴파일 단계에서 위험을 차단하는 접근이다.
핵심은 다음 세 가지로 요약된다.

  • 기본 타입은 null을 허용하지 않으며, null을 허용하려면 ?로 의도를 드러낸다.
  • nullable 값을 사용할 때는 if 체크, ?., ?? 같은 안전 장치를 통해 흐름을 설계한다.
  • !late는 편리하지만 런타임 예외 가능성이 있으므로 “구조적으로 안전함이 보장되는 경우”에만 사용한다.

Null Safety를 잘 활용하면 Flutter 개발에서도 오류가 줄고, 코드의 의도가 더 명확해진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.