지금까지 if 블록 안에서 nullable 변수를 null 체크 없이 사용하는 것을 당연하게 여겼을 겁니다. 하지만 어떻게 그게 가능할까요. Dart 컴파일러는 코드의 실행 흐름을 따라 변수의 타입이 언제 어떻게 변하는지 분석합니다. 이것을 흐름 분석(flow analysis)이라고 합니다. 그리고 흐름 분석의 결과로 타입이 자동으로 좁혀지는 것을 타입 프로모션(type promotion)이라고 합니다.
타입 프로모션이란
String? 타입 변수를 if (x != null) 블록 안에서 사용하면, 그 블록 안에서 변수의 타입은 자동으로 String이 됩니다. 개발자가 명시적으로 타입을 변환하지 않아도 됩니다. Dart가 알아서 해줍니다.
void main() {
String? name = '다트';
// 이 시점에서 name의 타입은 String?입니다
// name.length; // 컴파일 오류 — null일 수 있습니다
if (name != null) {
// 이 블록 안에서 name의 타입은 String으로 프로모션됩니다
print(name.length); // OK — null 체크 없이 사용 가능합니다
print(name.toUpperCase()); // OK
}
// 블록 밖으로 나오면 다시 String?로 돌아옵니다
// print(name.length); // 다시 컴파일 오류
}
흐름 분석은 코드의 모든 가능한 실행 경로를 분석합니다. if (name != null) 블록 안에 도달했다는 것은, 그 시점에 name이 반드시 null이 아님을 컴파일러가 알 수 있습니다.
프로모션이 동작하는 다양한 조건들
타입 프로모션은 != null 체크만으로 발생하는 것이 아닙니다. 다양한 조건과 연산자에서 작동합니다.
if와 else에서의 프로모션
void describe(String? text) {
if (text == null) {
// 이 블록에서 text의 타입은 Null입니다
print('텍스트가 없습니다');
return;
}
// 여기까지 왔다는 것은 text가 null이 아님을 의미합니다
// return으로 null인 경우를 걸러냈으므로 타입이 String으로 프로모션됩니다
print('텍스트 길이: ${text.length}'); // OK
}
조기 반환(early return) 패턴이 여기에 해당합니다. null인 경우 일찍 반환하면, 이후 코드에서는 변수가 non-nullable로 취급됩니다.
is 타입 체크에서의 프로모션
is 연산자로 타입을 확인하면 해당 블록에서 타입이 프로모션됩니다.
void printLength(Object? value) {
if (value is String) {
// 이 블록 안에서 value의 타입은 String입니다
print('문자열 길이: ${value.length}'); // OK
print(value.toUpperCase()); // OK
} else if (value is int) {
// 이 블록 안에서 value의 타입은 int입니다
print('정수의 두 배: ${value * 2}'); // OK
} else {
print('알 수 없는 타입: ${value.runtimeType}');
}
}
void main() {
printLength('hello'); // 문자열 길이: 5
printLength(42); // 정수의 두 배: 84
printLength(null); // 알 수 없는 타입: Null
}
&&와 ||에서의 프로모션
논리 연산자와 함께 사용해도 프로모션이 동작합니다.
void main() {
String? name = '다트';
int? age = 25;
// && 연산자 — 두 조건 모두 만족하는 블록 안에서 프로모션됩니다
if (name != null && age != null) {
print('$name은 $age세입니다'); // name과 age 모두 프로모션됩니다
}
// || 연산자에서는 프로모션이 동작하지 않습니다
if (name != null || age != null) {
// 이 블록에서는 name과 age 중 하나만 null이 아닐 수 있습니다
// print(name.length); // 컴파일 오류 — 프로모션 안 됩니다
}
}
프로모션이 안 되는 경우와 해결법
흐름 분석이 강력하지만, 모든 상황에서 작동하지는 않습니다. 프로모션이 안 되는 대표적인 케이스들을 알아봅니다.
클래스 필드는 프로모션이 안 됩니다
로컬 변수는 프로모션이 되지만, 클래스의 인스턴스 필드는 프로모션이 되지 않습니다. 이유가 있습니다. 컴파일러가 다른 스레드나 코드가 필드를 변경할 수 있다는 것을 알기 때문입니다.
class Person {
String? name;
Person({this.name});
}
void main() {
Person person = Person(name: '다트');
if (person.name != null) {
// 컴파일러는 이 블록 안에서 person.name이 여전히
// null로 변경될 수 있다고 판단합니다
// print(person.name.length); // 컴파일 오류!
}
}
이 상황을 해결하는 방법은 로컬 변수에 값을 복사하는 것입니다.
void main() {
Person person = Person(name: '다트');
// 로컬 변수에 복사합니다
final name = person.name;
if (name != null) {
// 로컬 변수는 프로모션이 됩니다
print(name.length); // OK — 4
print(name.toUpperCase()); // OK — 다트
}
}
클로저 캡처에서의 프로모션 한계
클로저(함수 안에서 정의된 함수)가 변수를 캡처할 때도 프로모션이 제한됩니다.
void main() {
String? value = '안녕';
if (value != null) {
// 이 블록 안에서 value는 String으로 프로모션됩니다
print(value.length); // OK
// 하지만 클로저가 value를 캡처하면 프로모션이 풀립니다
void inner() {
// inner 함수가 호출될 시점에 value가 변경될 수 있습니다
// print(value.length); // 컴파일러에 따라 오류 또는 경고
// 안전하게 사용하려면 다시 null 체크를 해야 합니다
if (value != null) {
print(value.length);
}
}
inner();
}
}
실제 코드에서는 로컬 변수에 복사하거나, 클로저 외부에서 null을 처리한 후 non-nullable 값을 전달하는 방식을 씁니다.
void main() {
String? value = '안녕';
if (value != null) {
final captured = value; // non-nullable 로컬 변수에 복사합니다
void inner() {
print(captured.length); // OK — captured는 String 타입입니다
}
inner();
}
}
변수가 재할당될 가능성이 있는 경우
변수가 null 체크와 사용 사이에 재할당될 수 있다면 프로모션이 유지되지 않습니다.
void main() {
String? name = '다트';
if (name != null) {
// 이 시점에서 name은 String으로 프로모션됩니다
print(name.length); // OK
name = null; // 재할당!
// 재할당 이후에는 프로모션이 풀립니다
// print(name.length); // 컴파일 오류 — 다시 String?
}
}
흐름 분석의 실전 패턴
흐름 분석을 잘 활용하는 실전 패턴들을 살펴봅니다.
패턴 1. 조기 반환으로 null 제거하기
함수 초반에 null 케이스를 처리하고 반환하면, 이후 코드가 깔끔해집니다.
String formatEmail(String? email) {
if (email == null) return '이메일 없음';
if (email.isEmpty) return '이메일 없음';
// 여기서부터 email은 String이고 비어있지 않습니다
final parts = email.split('@');
if (parts.length != 2) return '잘못된 형식';
return '${parts[0]}@${parts[1].toLowerCase()}';
}
void main() {
print(formatEmail(null)); // 이메일 없음
print(formatEmail('')); // 이메일 없음
print(formatEmail('[email protected]')); // [email protected]
}
패턴 2. assert로 개발 중 null 가정 검증하기
assert는 디버그 모드에서만 실행되는 조건 검사입니다. 흐름 분석에는 영향을 주지 않지만, 개발 중 잘못된 가정을 빠르게 발견할 수 있습니다.
void processOrder(Map<String, dynamic> order) {
final customerId = order['customerId'];
assert(customerId != null, 'customerId는 null이면 안 됩니다');
// assert는 흐름 분석에 영향을 주지 않으므로 null 체크가 여전히 필요합니다
if (customerId != null) {
print('주문 처리: 고객 $customerId');
}
}
패턴 3. 패턴 매칭으로 null 처리하기 (Dart 3.0+)
Dart 3.0의 패턴 매칭은 흐름 분석과 결합하여 강력한 null 처리를 가능하게 합니다.
void main() {
String? value = '다트 3.0';
// switch 표현식과 패턴 매칭
String result = switch (value) {
null => '값 없음',
String s when s.isEmpty => '빈 문자열',
String s => '값: ${s.toUpperCase()}',
};
print(result); // 값: 다트 3.0
}
패턴 4. 로컬 변수 복사로 필드 프로모션 우회하기
클래스 필드의 프로모션 한계를 극복하는 실전 패턴입니다.
class Order {
String? discountCode;
double price;
Order({required this.price, this.discountCode});
double get finalPrice {
// 필드는 프로모션이 안 되므로 로컬 변수에 복사합니다
final code = discountCode;
if (code == null) return price;
// code는 로컬 변수이므로 String으로 프로모션됩니다
if (code.startsWith('SALE')) {
return price * 0.9;
} else if (code.startsWith('VIP')) {
return price * 0.8;
}
return price;
}
}
void main() {
Order order1 = Order(price: 10000, discountCode: 'SALE2024');
Order order2 = Order(price: 10000, discountCode: 'VIP_CODE');
Order order3 = Order(price: 10000);
print(order1.finalPrice); // 9000.0
print(order2.finalPrice); // 8000.0
print(order3.finalPrice); // 10000.0
}
흐름 분석이 주는 선물
흐름 분석 덕분에 Dart 코드는 자연스럽게 읽힙니다. if (x != null) 이후에 x를 안전하게 사용할 수 있다는 것이 직관적입니다. 명시적인 타입 변환이나 as 연산자 없이도 null-safe 코드를 작성할 수 있습니다.
// 흐름 분석이 없다면 이렇게 써야 합니다 (다른 언어처럼)
// String name2 = (name as String).toUpperCase();
// 흐름 분석 덕분에 자연스럽게 씁니다
String? name = '다트';
if (name != null) {
String upper = name.toUpperCase(); // 자연스럽습니다
print(upper);
}
PART 06 마무리
이 PART에서 배운 것들을 돌아봅니다.
| 챕터 | 핵심 내용 |
|---|---|
| Ch 01 | Sound란 컴파일+런타임 타입 보장이 일치한다는 의미입니다 |
| Ch 02 | String과 String?은 완전히 다른 타입이며, Never와 Null은 특수한 타입입니다 |
| Ch 03 | ?., ??, ??=를 적극 활용하고 !는 신중하게 씁니다 |
| Ch 04 | late는 지연 초기화, required는 named 매개변수의 필수화입니다 |
| Ch 05 | 흐름 분석이 타입을 자동으로 승격하며, 필드와 클로저에서는 한계가 있습니다 |
Null Safety는 Dart를 "신뢰할 수 있는 언어"로 만드는 핵심 요소입니다. 컴파일러가 "이 코드는 런타임에서 절대 null 오류가 나지 않는다"고 보장해주는 것, 그것이 Sound Null Safety의 가치입니다.
다음 PART에서는 제네릭(Generic)을 배웁니다. List<String>이나 Map<String, int>처럼 타입 매개변수를 사용하는 코드를 작성하며, 타입 안전성을 유지하면서도 다양한 타입에서 재사용 가능한 코드를 만드는 방법을 익힙니다.