Dart 레코드·패턴·sealed 클래스, 실행과 측정으로 이해하기

레코드, 구조 분해, switch 식, 가드, sealed 클래스를 Dart 3.11 에서 실행. 실제 망라성 에러, jsonDecode 와 맵 패턴 JSON 파싱, AOT 메모리·속도 실측까지.

시리즈: Dart 강좌 4/6 (중급). 도우미 함수에서 Map<String, dynamic> 을 돌려주거나 if (x is ...) 를 몇 겹씩 쌓고 있는 Dart·Flutter 개발자를 위한 글입니다. 다 읽고 나면 레코드로 값을 여러 개 돌려주고, 패턴으로 데이터를 분해하고, sealed 클래스로 화면 상태를 표현해 빠진 case 를 컴파일러가 찾게 하고, as 캐스트 범벅 없이 JSON 을 파싱할 수 있습니다.

신입 때 짠 Flutter 코드를 다시 열어 보면 Dart 3 이전 습관 두 가지가 꼭 보입니다. "값 두 개를 돌려주려고" 만든 작은 클래스나 Map, 그리고 isLoading·error·data 를 nullable 로 늘어놓아 말이 안 되는 조합까지 허용하는 화면 상태. 레코드·패턴·sealed 클래스는 이 둘을 모두 해결하며, 셋 다 Dart 3.0 에서 함께 들어왔습니다. 이 글에서는 Dart 3.11 에서 각각 어떻게 동작하는지, 실제로 만나는 에러, 그리고 AOT 빌드로 잰 비용을 봅니다.

실행 환경: Dart SDK 3.11.5(stable, macos_arm64), Apple Silicon Mac(M1 Pro). 예제는 dart run(JIT)으로 실행했고, 시간과 메모리는 dart compile exe(AOT) 바이너리로 쟀습니다. 난수는 Random(42) 로 고정. 분석기 메시지는 dart analyze 출력입니다.

레코드: 위치 필드·이름 있는 필드·동등성

레코드는 (int, String) 이나 ({double lat, double lng}) 같은 타입을 갖는, 이름 없는 불변 값 묶음입니다.

(int, String) parse(String line) {
  final i = line.indexOf('=');
  return (int.parse(line.substring(i + 1)), line.substring(0, i));
}

({double lat, double lng}) tokyo() => (lat: 35.68, lng: 139.76);

void main() {
  final r = parse('port=8080');
  print(r);
  print('${r.$1} ${r.$2}');
  final (value, key) = parse('timeout=30');   // 구조 분해
  print('$key -> $value');

  final p = tokyo();
  print(p);
  final (:lat, :lng) = p;
  print('lat=$lat lng=$lng');

  print((1, 'a') == (1, 'a'));
  print((x: 1, y: 2) == (y: 2, x: 1));
  print((1, 2) == (2, 1));
  print({(1, 'a'), (1, 'a')}.length);
  print((1, 'a').runtimeType);
  print(identical((1, 2), (1, 2)));

  var (a, b) = (1, 2);
  (a, b) = (b, a);  // 맞바꾸기
  print('a=$a b=$b');
  final pos = (1, 2, label: 'p');
  print('${pos.$1} ${pos.$2} ${pos.label}');
}
(8080, port)
8080 port
timeout -> 30
(lat: 35.68, lng: 139.76)
lat=35.68 lng=139.76
true
true
false
1
(int, String)
false
a=2 b=1
1 2 p

위치 필드는 $1·$2 로 읽습니다(0 이 아니라 1 부터). 이름 있는 필드는 이름으로 읽고 순서는 상관없습니다. (x: 1, y: 2) == (y: 2, x: 1) 은 true 입니다. == 와 hashCode 는 내용으로 비교하는 구조적 동등성이라, 레코드 Set 은 중복을 제대로 걸러냅니다. 3강에서 본 "== 만 override 하고 hashCode 를 빠뜨린" 버그가 레코드에서는 생길 수 없습니다.

레코드는 const 가 아니면 정규화되지 않습니다. identical((1, 2), (1, 2)) 는 false, identical(const (1, 2), const (1, 2)) 는 true 였고, JIT 와 AOT 빌드 모두 같았습니다. 레코드는 identical 이 아니라 == 로 비교하세요.

패턴: 구조 분해·switch 식·if-case

패턴은 모양을 확인하면서 변수로 꺼냅니다. final (value, key) = ... 는 이미 봤습니다. 진짜 쓰임새는 switch 식입니다.

String bucket(int code) => switch (code) {
      >= 200 && < 300 => 'ok',
      301 || 302 || 307 || 308 => 'redirect',
      >= 400 && < 500 => 'client error',
      _ => 'other',
    };

void main() {
  print([200, 302, 404, 500].map(bucket).toList());

  final Object v = [1, 2, 3];
  if (v case [int first, ...final rest] when rest.isNotEmpty) {
    print('first=$first rest=$rest');
  }
}
[ok, redirect, client error, other]
first=1 rest=[2, 3]
  • >= 200 && < 300 은 관계 패턴을 논리곱으로 묶은 것.
  • 301 || 302 는 상수 패턴의 논리합.
  • if (v case ...) 는 switch 전체를 쓰지 않고 패턴 하나만 맞춰 봅니다. v 는 Object 타입이지만 리스트 패턴이 "첫 요소가 int 인 리스트인가"를 확인하면서 first 와 rest 를 꺼내므로 캐스트가 하나도 없습니다.
  • when 은 가드로, 추가로 참이어야 하는 평범한 조건식입니다.

(리다이렉트 코드는 switch 예제로 딱 좋습니다. Dart http 가 POST 의 301 을 따라가지 않는 문제가 바로 이 네 코드 이야기입니다.)

sealed 클래스와 빠짐없는 switch

sealed 클래스는 직접 하위 타입이 같은 라이브러리 안에 고정됩니다. 컴파일러가 목록 전체를 알기 때문에 switch 가 모든 경우를 다루는지 검사할 수 있습니다. Flutter 화면 상태에서 흔한 모양입니다.

sealed class LoadState {}
class Loading extends LoadState {}
class Loaded extends LoadState {
  final List<String> items;
  Loaded(this.items);
}
class Failed extends LoadState {
  final Object error;
  final int retries;
  Failed(this.error, {this.retries = 0});
}

String label(LoadState s) => switch (s) {
      Loading() => 'spinner',
      Loaded(items: []) => 'empty list',
      Loaded(:final items) when items.length > 100 => 'big list (${items.length})',
      Loaded(:final items) => '${items.length} items',
      Failed(retries: >= 3) => 'give up',
      Failed(:final error) => 'retry: $error',
    };

void main() {
  for (final s in [Loading(), Loaded([]), Loaded(List.filled(150, 'x')), Loaded(['a', 'b']),
      Failed('timeout'), Failed('timeout', retries: 3)]) {
    print('${s.runtimeType.toString().padRight(8)} -> ${label(s)}');
  }
}
Loading  -> spinner
Loaded   -> empty list
Loaded   -> big list (150)
Loaded   -> 2 items
Failed   -> retry: timeout
Failed   -> give up

Loaded(:final items) 는 객체 패턴으로, 타입을 확인하면서 items getter 를 같은 이름의 변수로 꺼냅니다. Loaded(items: []) 는 빈 리스트일 때만 맞습니다. 위젯의 build() 에서는 같은 switch 로 CircularProgressIndicator·ListView·에러 화면을 돌려주게 됩니다.

흐름도. sealed 클래스 LoadState 가 switch 식으로 들어간다. 분기는 세 개로, Loading 은 스피너, Loaded 는 리스트 화면, Failed 는 에러 화면으로 간다. 새로 추가한 하위 타입 Refreshing 에서 나온 점선 화살표는 컴파일 에러 non_exhaustive_switch_expression 으로 간다.
sealed LoadState 하나가 switch 식 하나를 움직인다. 컴파일러가 모든 하위 타입을 알기 때문에, case 없이 새 하위 타입을 추가하면 빈 화면이 아니라 컴파일 에러가 난다.

이제 핵심입니다. 팀원이 class Refreshing extends LoadState {} 를 추가하고 UI 처리를 깜빡했다고 합시다.

error - nonex.dart:7:30 - The type 'LoadState' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Refreshing()'. Try adding a wildcard pattern or cases that match 'Refreshing()'. - non_exhaustive_switch_expression

dart run 도 Error: The type 'LoadState' is not exhaustively matched by the switch cases since it doesn't match 'Refreshing()'. 로 멈춥니다. 빠진 case 를 이름으로 짚어 줍니다. 상태를 bool 조합이 아니라 sealed 클래스로 표현하는 이유가 바로 이것입니다.

망라성(exhaustiveness)에서 흔한 실수

실수 1: sealed 대신 abstract 를 쓴다. abstract class LoadState 에 같은 case 두 개만 쓰면 switch 식 에러가 doesn't match the pattern 'LoadState()' 로 바뀝니다. 컴파일러가 하위 클래스 목록을 모르니 와일드카드를 요구할 수밖에 없습니다. 더 나쁜 건, abstract 클래스에 대한 switch 문은 아무 진단 없이 컴파일되고, 처리하지 않은 하위 타입은 조용히 지나간다는 점입니다. 고치는 법: sealed 로 바꾸고, sealed 타입에는 _ => 를 쓰지 않아서 새 하위 타입이 빌드를 깨뜨리게 합니다.

실수 2: 가드도 망라성에 포함된다고 생각한다.

sealed class R {}
class Ok extends R { final int v; Ok(this.v); }
class Err extends R {}
String a(R r) => switch (r) {
      Ok(:final v) when v > 0 => 'positive',
      Ok(:final v) when v <= 0 => 'not positive',
      Err() => 'err',
    };
error - The type 'R' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Ok()'. … - non_exhaustive_switch_expression

사람 눈에는 v > 0 과 v <= 0 이 전부를 덮지만, 컴파일러는 가드 안의 식을 추론하지 않습니다. 고치는 법: Ok 의 마지막 case 를 가드 없는 Ok() => 'not positive' 로 바꿉니다.

실수 3: 절대 실행되지 않는 case. Ok() => 'ok' 를 Ok(v: 0) => 'zero' 앞에 두면 warning - This case is covered by the previous cases. … - unreachable_switch_case 가 뜹니다. 구체적인 case 를 먼저 둡니다.

맵 패턴과 jsonDecode 로 JSON 파싱하기

Flutter 앱에서 패턴이 가장 빛나는 곳입니다. 내용을 완전히 통제할 수 없는 "업데이트 확인 API" 의 JSON 을 읽어 봅니다. 강제 업데이트 팝업을 붙여 본 분이라면 익숙한 모양일 겁니다.

import 'dart:convert';

final class AppVersion {
  final String platform;
  final String latest;
  final int minBuild;
  final String? notes;
  const AppVersion(this.platform, this.latest, this.minBuild, this.notes);
  @override
  String toString() => 'AppVersion($platform $latest, min build $minBuild, notes: $notes)';
}

AppVersion? parseVersion(Object? json) {
  if (json case {
        'platform': final String platform && ('ios' || 'android'),
        'latest': final String latest,
        'min_build': final int minBuild,
      }) {
    final notes = switch (json['notes']) { final String s => s, _ => null };
    return AppVersion(platform, latest, minBuild, notes);
  }
  return null;
}

AppVersion parseVersionManual(Map<String, dynamic> json) => AppVersion(
      json['platform'] as String,
      json['latest'] as String,
      json['min_build'] as int,
      json['notes'] as String?,
    );

void main() {
  const bodies = [
    '{"platform":"ios","latest":"2.4.0","min_build":120,"notes":"bug fixes"}',
    '{"platform":"android","latest":"2.4.0","min_build":118}',
    '{"platform":"web","latest":"2.4.0","min_build":1}',
    '{"platform":"ios","latest":"2.4.0","min_build":"120"}',
    '{"platform":"ios","latest":"2.4.0","min_build":120.0}',
    '[1,2,3]',
  ];
  for (final body in bodies) {
    print(parseVersion(jsonDecode(body)));
  }
  print('--- manual casts on the 4th body:');
  try {
    parseVersionManual(jsonDecode(bodies[3]) as Map<String, dynamic>);
  } catch (e) {
    print('${e.runtimeType}: $e');
  }
  print('--- extra keys are fine for map patterns:');
  print(parseVersion(jsonDecode('{"platform":"ios","latest":"3.0.0","min_build":200,"force":true}')));
  final decoded = jsonDecode('{"a":1}');
  print('jsonDecode map type: ${decoded.runtimeType}');
}
AppVersion(ios 2.4.0, min build 120, notes: bug fixes)
AppVersion(android 2.4.0, min build 118, notes: null)
null
null
null
null
--- manual casts on the 4th body:
_TypeError: type 'String' is not a subtype of type 'int' in type cast
--- extra keys are fine for map patterns:
AppVersion(ios 3.0.0, min build 200, notes: null)
jsonDecode map type: _Map<String, dynamic>

패턴 하나가 예외 하나 없이 잘못된 입력 네 가지를 걸러냈습니다. 모르는 플랫폼, 문자열 "120", double 120.0, 객체 대신 배열. 의외인 건 120.0 입니다. jsonDecode 는 소수점이 있는 숫자를 double 로 돌려주므로 final int 와 맞지 않습니다. 캐스트 버전은 _TypeError 로 죽었습니다. Flutter 앱이라면 디버그에서는 빨간 화면, 릴리스에서는 깨진 화면입니다. 서버 쪽 숫자 타입은 실제로 바뀝니다. 프리페어드 스테이트먼트로 JSON 타입이 바뀐 PHP 글에 "7" 이 7 이 되면서 예전 Flutter 빌드가 죽은 실제 사례를 적어 두었습니다.

맵 패턴은 남는 키를 무시합니다("force": true 가 있어도 통과). 서버가 필드를 늘려도 예전 앱이 깨지지 않습니다.

우리가 처음 한 실수. 첫 버전에서는 플랫폼 검사를 'platform': 'ios' || 'android' && final String platform 으로 썼는데 컴파일되지 않았습니다.

error - json.dart:16:21 - Variable pattern 'platform' is missing in this branch of the logical-or pattern. Try declaring this variable pattern in the branch. - missing_variable_pattern

&& 가 || 보다 강하게 묶이므로 이 식은 'ios' || ('android' && final String platform) 으로 읽히고, 'ios' 쪽 분기에는 변수가 없습니다. 고치는 법: 먼저 묶고 나중에 확인하며 괄호를 칩니다. final String platform && ('ios' || 'android').

실측: 레코드 대 작은 클래스, 패턴 대 캐스트 (AOT)

모두 AOT 빌드(dart compile exe), 워밍업 3회 뒤 9회 측정의 중앙값, 데이터는 Random(42) 입니다.

1. int 두 개를 돌려주는 함수 1,000만 번. mmRecord 는 (int, int), mmClass 는 final class MinMax 를 돌려줍니다. @pragma('vm:never-inline') 를 붙인 경우(진짜 함수 호출)와 뺀 경우(인라인 가능) 둘 다 쟀습니다.

record, never-inline (10M)         23.1 ms
class, never-inline (10M)          43.1 ms
record, inlinable (10M)            13.2 ms
class, inlinable (10M)             13.2 ms
json: map pattern (200k x5)        45.6 ms
json: as casts (200k x5)           41.2 ms

두 번째 실행은 23.4 / 47.4 / 13.3 / 13.4 / 47.2 / 42.6ms 였습니다. 인라인되면 둘은 똑같았고, 양쪽 모두 할당이 사라졌습니다. 진짜 호출을 거치면 AOT 에서 레코드가 약 2배 빨랐습니다. JIT(dart run)에서는 그 차이가 사라졌습니다(69.1 대 66.7ms). AOT 컴파일러가 레코드를 할당하지 않고 필드만 돌려준다고 보면 들어맞지만, 기계어를 확인하지는 않았으니 "아마도"인 설명으로 읽어 주세요.

막대그래프. 인라인을 막은 호출에서 레코드를 돌려주면 23.1밀리초, 작은 클래스를 돌려주면 43.1밀리초. 인라인을 허용하면 둘 다 13.2밀리초.
함수에서 int 두 개를 1,000만 번 돌려받는 시간. 워밍업 3회 뒤 측정 9회의 중앙값(Dart 3.11.5 AOT, Apple Silicon Mac M1 Pro). 인라인되면 둘 다 13.2ms 이고, 차이는 실제 함수 호출일 때만 생긴다.

2. 레코드를 담아 둘 때의 메모리. 100만 개를 List 에 넣고 새 프로세스에서 RSS 증가를 봤습니다. 기준은 int 만 담은 리스트입니다(전체 프로그램은 「직접 돌려 보기」에).

baseline  1M: +7.6 MiB  (1000000)
record    1M: +40.9 MiB  (1000000)
named     1M: +41.0 MiB  (1000000)
class     1M: +31.0 MiB  (1000000)
record-d  1M: +77.5 MiB  (1000000)
class-d   1M: +31.0 MiB  (1000000)

record 는 (i, i + 1), named 는 (min: i, max: i + 1), class 는 final class MinMax { final int min, max; }, record-d 는 (i * 0.5, i + 0.5), class-d 는 final class Pt { final double x, y; } 입니다. 두 번째 실행도 0.7MiB 안쪽으로 같았습니다. 담아 두면 레코드가 같은 내용의 클래스보다 더 크고, double 이면 두 배가 넘었습니다. 클래스는 final double 필드를 박싱하지 않고 들고 있지만, 레코드 필드는 참조를 들고 있어서 double 하나하나가 힙 객체가 됩니다. 좌표 100만 개를 들고 있어야 한다면 클래스를 쓰세요.

값 100만 개를 담았을 때 메모리 증가 막대그래프. int 리스트 7.6 MiB, int 필드 두 개 클래스 31.0, (int, int) 레코드 40.9, double 필드 두 개 클래스 31.0, (double, double) 레코드 77.5.
값 100만 개를 List 에 담았을 때의 RSS 증가(Dart 3.11.5 AOT, Apple Silicon Mac M1 Pro). int 만 담은 리스트는 7.6MiB. 레코드 필드는 double 을 박싱해서 들고, 클래스 필드는 박싱하지 않는다.

3. 맵 패턴 대 as 캐스트. 디코딩된 JSON 객체 20만 개를 5번 파싱(총 100만 번). 패턴 45.6ms, 캐스트 41.2ms 로 약 10% 느렸고, JIT 에서는 42.7 대 41.0ms 였습니다. 맵 패턴은 키가 있는지 먼저 확인한 뒤 읽기 때문에 그만큼 일이 늘어난 것으로 보입니다. 객체 하나당 약 45ns 차이입니다. API 응답은 보통 한 번만 파싱하니, 잘못된 입력에 강한 쪽을 고르세요.

직접 돌려 보기

둘 다 SDK 만으로 돌아갑니다. 저장한 뒤 dart compile exe bench.dart -o bench && ./bench, dart compile exe mem.dart -o mem && ./mem record 를 실행하세요(모드는 baseline·record·named·class·record-d·class-d).

import 'dart:convert';
import 'dart:math';

final class MinMax {
  final int min, max;
  const MinMax(this.min, this.max);
}

@pragma('vm:never-inline')
(int, int) mmRecord(List<int> xs, int i) => (xs[i], xs[i + 1]);
@pragma('vm:never-inline')
MinMax mmClass(List<int> xs, int i) => MinMax(xs[i], xs[i + 1]);

(int, int) mmRecordInl(List<int> xs, int i) => (xs[i], xs[i + 1]);
MinMax mmClassInl(List<int> xs, int i) => MinMax(xs[i], xs[i + 1]);

final class Item {
  final String id;
  final int qty;
  final String? note;
  Item(this.id, this.qty, this.note);
}

Item? parsePattern(Object? j) {
  if (j case {'id': final String id, 'qty': final int qty}) {
    return Item(id, qty, j['note'] as String?);
  }
  return null;
}

Item parseCast(Map<String, dynamic> j) =>
    Item(j['id'] as String, j['qty'] as int, j['note'] as String?);

int sink = 0;

double median(void Function() f, {int warm = 3, int runs = 9}) {
  for (var i = 0; i < warm; i++) f();
  final t = <double>[];
  for (var i = 0; i < runs; i++) {
    final sw = Stopwatch()..start();
    f();
    t.add(sw.elapsedMicroseconds / 1000);
  }
  t.sort();
  return t[runs ~/ 2];
}

void main() {
  final rnd = Random(42);
  const n = 10000000;
  final xs = List<int>.generate(n + 1, (_) => rnd.nextInt(1000));
  void report(String name, double ms) => print('${name.padRight(34)} ${ms.toStringAsFixed(1)} ms');

  report('record, never-inline (10M)', median(() {
    var s = 0;
    for (var i = 0; i < n; i++) { final (a, b) = mmRecord(xs, i); s += a - b; }
    sink += s;
  }));
  report('class, never-inline (10M)', median(() {
    var s = 0;
    for (var i = 0; i < n; i++) { final m = mmClass(xs, i); s += m.min - m.max; }
    sink += s;
  }));
  report('record, inlinable (10M)', median(() {
    var s = 0;
    for (var i = 0; i < n; i++) { final (a, b) = mmRecordInl(xs, i); s += a - b; }
    sink += s;
  }));
  report('class, inlinable (10M)', median(() {
    var s = 0;
    for (var i = 0; i < n; i++) { final m = mmClassInl(xs, i); s += m.min - m.max; }
    sink += s;
  }));

  final rows = List.generate(200000, (i) => {'id': 'sku-$i', 'qty': rnd.nextInt(50), if (i.isEven) 'note': 'n$i'});
  final decoded = (jsonDecode(jsonEncode(rows)) as List).cast<Map<String, dynamic>>();
  report('json: map pattern (200k x5)', median(() {
    var s = 0;
    for (var k = 0; k < 5; k++) for (final r in decoded) { s += parsePattern(r)!.qty; }
    sink += s;
  }));
  report('json: as casts (200k x5)', median(() {
    var s = 0;
    for (var k = 0; k < 5; k++) for (final r in decoded) { s += parseCast(r).qty; }
    sink += s;
  }));
  print('sink ${sink != 0}');
}
import 'dart:io';
final class MinMax { final int min, max; const MinMax(this.min, this.max); }
final class Pt { final double x, y; Pt(this.x, this.y); }
void main(List<String> a) {
  const n = 1000000;
  final before = ProcessInfo.currentRss;
  final List<Object> l = switch (a[0]) {
    'record' => List.generate(n, (i) => (i, i + 1)),
    'class' => List.generate(n, (i) => MinMax(i, i + 1)),
    'record-d' => List.generate(n, (i) => (i * 0.5, i + 0.5)),
    'class-d' => List.generate(n, (i) => Pt(i * 0.5, i + 0.5)),
    'named' => List.generate(n, (i) => (min: i, max: i + 1)),
    _ => List.generate(n, (i) => i),
  };
  final after = ProcessInfo.currentRss;
  print('${a[0].padRight(9)} 1M: +${((after - before) / 1048576).toStringAsFixed(1)} MiB  (${l.length})');
}

결정표

하고 싶은 것 쓸 것 이유
private 도우미에서 값 2~3개 돌려주기 레코드 (a, b) 클래스 이름이 필요 없고 == 도 올바름
여러 파일에서 쓰는 값 타입, 메서드 포함 클래스(또는 final class) 이름·문서·메서드
숫자 수백만 개를 메모리에 유지 final double 필드 클래스 레코드는 double 을 박싱(77.5 대 31.0MiB)
정해진 종류의 상태나 결과 sealed class + switch 식 빠진 case 를 컴파일러가 찾음
정해진 종류의 단순 상수 enum switch 에서 망라성 검사
믿을 수 없는 JSON if-case 맵 패턴 예외 없이 잘못된 타입을 거름
믿을 수 있는, 생성된 JSON 모델 캐스트나 코드 생성 fromJson 조금 빠르고 코드가 짧음
여러 필드로 된 Set/Map 키 레코드 구조적 hashCode 가 공짜

한계와 버전 메모

"Dart 3.0 이상"이라는 설명은 레코드 타입·sealed·구조 분해를 쓰는 파일 맨 위에 // @dart=2.19 를 붙여 확인했습니다.

error - old219.dart:2:1 - This requires the 'records' language feature to be enabled. Try updating your pubspec.yaml to set the minimum SDK constraint to 3.0.0 or higher, and running 'pub get'. - experiment_not_enabled
error - old219.dart:3:1 - This requires the 'sealed-class' language feature to be enabled. … - experiment_not_enabled
error - old219.dart:5:9 - Expected an identifier. - missing_identifier
error - old219.dart:5:9 - Expected to find ';'. - expected_token
…
21 issues found.

레코드와 sealed 에는 알아보기 쉬운 "기능" 메시지가 나오지만 패턴에는 나오지 않습니다. 2.19 에서는 final (a, b) = pair(); 가 아예 구문으로 읽히지 않아 missing_identifier/expected_token 이 잔뜩 나옵니다. 멀쩡해 보이는 Dart 3 코드에서 이런 에러가 나오면 먼저 pubspec.yaml 의 SDK 제약부터 보세요. // @dart=3.0 으로 바꾸자 같은 파일이 1 a one 을 출력했습니다.

  • 실행은 Dart 3.11.5 에서만 했고, 3.0 SDK 는 설치하지 않았습니다.
  • 메모리 수치는 RSS 증가라 GC 힙 여유분이 섞여 있습니다. 두 번 실행해도 안정적이었지만 상대 비교로 보세요.
  • never-inline 측정은 실제 코드에는 없을 수도 있는 호출을 강제한 것입니다. 인라인되면 레코드와 클래스는 같았습니다.
  • 이번 강좌에서는 Flutter 앱을 돌리지 않았습니다. Flutter 관련 부분은 위젯의 build() 가 부르는 평범한 Dart 코드입니다.

연습 문제

문제 1. sealed class Shape 와 Circle(double r)·Rect(double w, double h) 에 대해 와일드카드 없는 switch 식으로 double area(Shape s) 를 작성하세요. 그다음 Square(double side) 를 추가하고 에러를 읽어 보세요.

문제 2. {"user": {"id": 7, "tags": ["a", "b"]}} 를 if-case 하나로 레코드 (int id, List<String> tags) 로 바꾸세요. tags 가 없거나 문자열이 아닌 값이 섞여 있으면 null 을 돌려줍니다.

정답

1.

sealed class Shape {}
class Circle extends Shape { final double r; Circle(this.r); }
class Rect extends Shape { final double w, h; Rect(this.w, this.h); }

double area(Shape s) => switch (s) {
      Circle(:final r) => 3.141592653589793 * r * r,
      Rect(:final w, :final h) => w * h,
    };

class Square extends Shape { ... } 를 추가하자 non_exhaustive_switch_expression ... doesn't match the pattern 'Square()' 가 나왔습니다.

2.

(int, List<String>)? parseUser(Object? json) {
  if (json case {'user': {'id': final int id, 'tags': final List<Object?> raw}}
      when raw.every((t) => t is String)) {
    return (id, raw.cast<String>());
  }
  return null;
}

jsonDecode 가 만드는 건 List<dynamic> 이라서 List<String> 패턴은 절대 맞지 않습니다(직접 해 보니 null 이었습니다). 요소는 가드로 확인합니다. 위 입력에서는 (7, [a, b]) 가 출력됐습니다.


관련 글: 자바 클래스·레코드·인터페이스 · Swift 에러 처리와 제네릭 · PHP enum 과 인터페이스

이전: 3강: 클래스·mixin·확장 메서드 · 다음: 5강: 비동기·Future·Stream·Isolate

자주 묻는 질문

Dart 레코드는 작은 클래스보다 빠르거나 작은가요?

경우에 따라 다릅니다. 인라인되지 않는 함수에서 값 두 개를 1,000만 번 돌려받으면 레코드 23.1ms, 클래스 43.1ms 였습니다(AOT, Apple Silicon Mac M1 Pro). 반대로 리스트에 100만 개를 담으면 (int, int) 레코드는 RSS +40.9MiB, 클래스는 +31.0MiB, (double, double) 레코드는 double 이 박싱돼 +77.5MiB 였습니다.

sealed 클래스에 새 하위 클래스를 추가하면 어떻게 되나요?

그 타입을 다루는 switch 식 중 새 case 가 없는 곳이 모두 non_exhaustive_switch_expression 으로 컴파일되지 않고, "doesn't match the pattern 'Refreshing()'" 처럼 빠진 case 를 정확히 짚어 줍니다. 그냥 abstract 클래스라면 와일드카드를 넣으라는 말만 하고, switch 문은 경고 없이 통과합니다.

레코드와 패턴은 Dart 3 가 필요한가요?

네. // @dart=2.19 를 붙이면 레코드와 sealed 에는 experiment_not_enabled 가 뜨고, final (a, b) = pair(); 같은 구조 분해는 missing_identifier·expected_token 구문 에러 무더기가 됩니다. // @dart=3.0 이면 같은 파일이 실행됩니다.

  • #Dart
  • #Flutter
  • #레코드
  • #패턴 매칭
  • #sealed 클래스
  • #JSON
  • #Dart 3
  • #중급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑