다트 함수와 컬렉션: 지연 Iterable 과 실제 오류

다트 강좌 2. 이름 있는·required 매개변수, 클로저, collection if/for, where/map 지연 평가, ConcurrentModificationError, Set 과 List 검색 속도 실측.

시리즈: 다트 강좌 2/6 (초급). 1강을 마쳤거나 다트로 main() 정도는 쓸 수 있는 분이, 이제 데이터를 주고받는 단계로 넘어가기 위한 글입니다. 다 읽으면 이름 있는 매개변수와 required 가 붙은 함수를 선언하고, 플러터 코드와 같은 방식으로 리스트와 맵을 만들고, 실제 크래시 리포트에 나오는 컬렉션 오류 세 가지를 피할 수 있습니다.

플러터의 build() 메서드는 거의 함수와 컬렉션으로 이루어져 있습니다. 위젯 생성자는 이름 있는 매개변수를 쓰는 함수 호출이고, 목록 화면은 대개 [for (final item in items) ItemTile(item)] 로 만듭니다. 자바 컬렉션 프레임워크로 ArrayList 와 HashSet 을 배운 분이라면 개념은 익숙하겠지만, 다트의 where/map 이 "언제 실행되는지" 는 대부분 처음에 놀랍니다. 그래서 그 부분은 실제 출력으로 확인합니다. 아래는 모두 Apple Silicon Mac(M1 Pro)의 Dart SDK 3.11.5(stable) 로 실행한 결과이고, 출력 속 경로는 ~/demo/ 로 바꿨습니다.

함수: 위치 매개변수·선택 매개변수·이름 있는 매개변수

함수는 매개변수(입력)를 받아 값을 돌려주는, 이름이 붙은 코드 묶음입니다. 다트의 매개변수는 세 종류입니다.

// Positional, optional positional, named, required named
String greet(String name, [String greeting = 'Hello']) => '$greeting, $name!';

String badge({required String user, int level = 1, String? team}) =>
    '$user (Lv.$level${team == null ? '' : ', $team'})';

int square(int x) => x * x;            // arrow = one expression

void main() {
  print(greet('Mina'));
  print(greet('Jun', 'Hi'));
  print(badge(user: 'mina'));
  print(badge(level: 3, user: 'jun', team: 'iOS'));
}
Hello, Mina!
Hi, Jun!
mina (Lv.1)
jun (Lv.3, iOS)
  • 위치 매개변수(name)는 순서로 짝지어지며 필수입니다.
  • 선택 위치 매개변수는 [ ] 로 감싸고, 기본값이나 null 허용 타입이 필요합니다.
  • 이름 있는 매개변수는 { } 로 감싸고 user: 'mina' 처럼 순서와 상관없이 넘깁니다. required 를 붙이지 않으면 생략할 수 있습니다.
  • =>(화살표 문법)는 { return …; } 의 줄임이고 식 하나일 때만 씁니다.

자바에는 이름 있는 인자가 없어서 빌더 패턴이나 오버로딩을 썼을 텐데, 다트는 언어가 직접 지원합니다. 플러터 코드가 Text('Hi', style: …, maxLines: 2) 처럼 읽히는 이유이고, 위젯 생성자는 전부 이름 있는 매개변수를 쓰는 함수입니다.

잘못 호출했을 때의 오류

String badge({required String user, int level = 1}) => '$user $level';
String greet(String name, [String greeting = 'Hello']) => '$greeting $name';
void setTitle({String title}) => print(title);

void main() {
  print(badge(level: 2));
  print(greet());
  print(greet('a', 'b', 'c'));
  print(badge('mina'));
}
$ dart analyze func_errors.dart
  error - func_errors.dart:3:23 - The parameter 'title' can't have a value of 'null' because of its type, but the implicit default value is 'null'. Try adding either an explicit non-'null' default value or the 'required' modifier. - missing_default_value_for_parameter
  error - func_errors.dart:6:9 - The named parameter 'user' is required, but there's no corresponding argument. Try adding the required argument. - missing_required_argument
  error - func_errors.dart:7:15 - 1 positional argument expected by 'greet', but 0 found. Try adding the missing argument. - not_enough_positional_arguments
  error - func_errors.dart:8:25 - Too many positional arguments: 2 expected, but 3 found. Try removing the extra arguments. - extra_positional_arguments
  error - func_errors.dart:9:15 - Too many positional arguments: 0 expected, but 1 found. Try removing the extra positional arguments, or specifying the name for named arguments. - extra_positional_arguments_could_be_named

첫 번째는 1강의 널 안전성 규칙이 다른 곳에서 나타난 것입니다. 이름 있는 매개변수는 생략할 수 있으니 생략하면 null 이 되는데, String 에는 null 을 넣을 수 없습니다. 고치는 방법은 String? title, String title = '', required String title 세 가지입니다. 마지막 줄은 badge('mina') 와 badge(user: 'mina') 가 다르다는 것을 알려 줍니다. 분석기도 "or specifying the name for named arguments" 라고 적어 줍니다.

함수는 값이고, 클로저는 기억한다

다트의 함수는 변수에 담고, 다른 함수에 넘기고, 함수에서 돌려줄 수 있습니다. 만들어진 곳의 변수를 계속 쓰는 함수를 클로저라고 합니다.

int Function() makeCounter() {
  var count = 0;                       // captured by the closure below
  return () => ++count;
}

void main() {
  final c1 = makeCounter();
  final c2 = makeCounter();
  print('${c1()} ${c1()} ${c1()} | ${c2()}');

  final ops = <String, int Function(int)>{'square': square, 'neg': (x) => -x};
  print(ops['square']!(7));
  print([1, 2, 3].map(square).toList());

  final callbacks = <void Function()>[];
  for (var i = 0; i < 3; i++) {
    callbacks.add(() => print('i = $i'));
  }
  for (final cb in callbacks) cb();
}
1 2 3 | 1
49
[1, 4, 9]
i = 0
i = 1
i = 2

makeCounter() 를 부를 때마다 새 count 가 생기므로 c1 과 c2 는 따로 셉니다. int Function() 은 함수 타입("인자 없이 int 를 돌려줌")이고, 써 두면 호출하는 쪽을 컴파일러가 검사합니다. map(square) 는 함수 자체를 넘긴 것이지 호출한 것이 아닙니다(괄호가 없음).

루프 결과도 눈여겨보세요. 자바스크립트의 옛 var 나 파이썬을 아는 분은 3, 3, 3 을 예상하기 쉽지만, 다트는 for 루프 한 바퀴마다 새 i 를 만들어서 0, 1, 2 가 나옵니다. 자바라면 람다 안에서 바뀌는 i 를 쓰는 것 자체가 컴파일 오류(effectively final)였을 부분입니다. 루프 안의 onPressed: () => select(i) 가 기대대로 동작하는 이유입니다.

List·Set·Map 과 collection if/for

final apps = ['Calculator', 'Scanner', 'Lottery'];      // List<String>
final tags = {'flutter', 'dart', 'flutter'};             // Set<String>
final downloads = {'Calculator': 1200, 'Scanner': 300};  // Map<String, int>
final empty = {};                                        // what type is this?

print('$apps ${apps.length} ${apps[0]}');
print('$tags ${tags.length}');
print('${downloads['Scanner']} ${downloads['Unknown']}');
print('${empty.runtimeType} ${<String>{}.runtimeType}');
[Calculator, Scanner, Lottery] 3 Calculator
{flutter, dart} 2
300 null
_Map<dynamic, dynamic> _Set<String>
  • List 는 순서가 있고 중복을 허용합니다. 인덱스는 0부터.
  • Set 은 같은 값을 하나만 가집니다. 리터럴의 중복에는 분석기가 경고했습니다: Two elements in a set literal shouldn't be equal. ... - equal_elements_in_set.
  • Map 은 키 → 값 쌍을 저장합니다. 없는 키를 찾으면 null 이 나오므로 downloads['Scanner'] 의 타입은 int 가 아니라 int? 입니다. 자바의 HashMap.get() 이 null 을 돌려주던 것을 다트는 타입으로 드러내는 셈입니다.
  • {} 는 빈 Map<dynamic, dynamic> 이지 Set 이 아닙니다. 빈 Set 은 <String>{} 로 씁니다. final seen = {}; 다음에 seen.add('x') 를 쓰면 The method 'add' isn't defined for the type 'Map'. ... - undefined_method 가 나서 많은 초보자가 고개를 갸웃합니다.
필요한 것 쓸 것 찾는 방법
순서대로, 중복 허용, 위치로 접근 List 인덱스 O(1), 값으로 찾기는 O(n)
"이미 봤나?" / 중복 없는 값 Set 값으로 평균 O(1)
ID 나 이름으로 값 찾기 Map 키로 평균 O(1)

collection if·for 와 스프레드

컬렉션 리터럴 안에서 if, for, ...(스프레드)를 쓸 수 있습니다. 플러터 레이아웃에서 가장 많이 쓰는 기능입니다.

const isPro = true;
final List<String>? extra = null;
final menu = [
  'Home',
  if (isPro) 'Stats',
  for (final a in apps) 'Open $a',
  ...?extra,
  ...['Settings'],
];
print(menu);

final byLength = {for (final a in apps) a: a.length};
print(byLength);
[Home, Stats, Open Calculator, Open Scanner, Open Lottery, Settings]
{Calculator: 10, Scanner: 7, Lottery: 7}

...?extra 는 null 일 수도 있는 리스트를 펼칩니다(null 이면 아무것도 넣지 않음). 위젯 트리에서는 children: [Header(), if (loggedIn) Profile(), for (final m in messages) MessageTile(m)] 모양이 됩니다.

맵 순회와 갱신:

for (final MapEntry(:key, :value) in downloads.entries) {
  print('$key -> $value');
}
downloads.update('Scanner', (v) => v + 1);
downloads.putIfAbsent('Lottery', () => 0);
print(downloads);
Calculator -> 1200
Scanner -> 300
{Calculator: 1200, Scanner: 301, Lottery: 0}

기본 Map 은 LinkedHashMap 이라서 넣은 순서를 지킵니다. 자바의 HashMap 처럼 순서가 섞이지 않습니다.

Iterable 은 지연 평가: 콜백은 언제 실행될까?

where(거르기)와 map(변환)은 리스트를 돌려주지 않습니다. 돌려주는 것은 Iterable, 즉 할 일의 "설명서" 입니다. 안의 print 가 실제로 언제 실행되는지 보세요.

void main() {
  final numbers = [1, 2, 3, 4, 5, 6];

  final evens = numbers.where((n) {
    print('  where checks $n');
    return n.isEven;
  }).map((n) {
    print('  map doubles $n');
    return n * 2;
  });

  print('1. pipeline built, type: ${evens.runtimeType}');
  print('2. first = ${evens.first}');
  print('3. toList:');
  final list = evens.toList();
  print('4. list = $list');
  print('5. iterate the lazy one again:');
  for (final x in evens) {
    print('  got $x');
  }

  numbers.add(8);
  print('6. after add(8): ${evens.toList()} vs frozen $list');
}
1. pipeline built, type: MappedIterable<int, int>
  where checks 1
  where checks 2
  map doubles 2
2. first = 4
3. toList:
  where checks 1
  where checks 2
  map doubles 2
  where checks 3
  ...
  map doubles 6
4. list = [4, 8, 12]
5. iterate the lazy one again:
  where checks 1
  where checks 2
  map doubles 2
  got 4
  where checks 3
  ...
  got 12
  where checks 1
  ...
  where checks 8
  map doubles 8
6. after add(8): [4, 8, 12, 16] vs frozen [4, 8, 12]

(출력은 … 로 줄였습니다. 모든 요소에 같은 패턴이 이어집니다.)

이 실행에서 알 수 있는 것:

  1. 파이프라인을 만든 시점에는 콜백이 0번 실행됐다.
  2. .first 는 결과가 하나 나오자 멈췄다. 검사 2번, map 1번.
  3. .toList() 는 전부를 한 번씩 처리하고 결과를 저장했다.
  4. for 루프는 파이프라인 전체를 다시 실행했고, 요소마다 where·map·루프 본문이 번갈아 돌았다.
  5. 지연된 evens 는 "살아 있는 뷰" 라서 numbers.add(8) 뒤에는 16 도 나왔다. 저장한 리스트는 [4, 8, 12] 그대로다.
흐름 그림. List 가 where, map 을 거쳐 지연 Iterable 이 되지만 이 시점에는 콜백이 한 번도 실행되지 않았다. 여기서 세 소비자가 요소를 꺼낸다. .first 는 첫 일치에서 멈추고, .toList() 는 모든 요소를 한 번씩 처리해 결과를 저장하며, for-in 루프는 전부를 다시 실행한다. 점선 메모: 어느 소비자든 원본 리스트부터 다시 시작하므로 콜백이 다시 실행되고, 나중에 바뀐 리스트 내용도 반영된다.
where() 와 map() 은 '조리법'만 만든다. 요소를 달라고 할 때까지 아무것도 실행되지 않고, 요청할 때마다 처음부터 다시 실행한다.

실무 규칙: 콜백에 부수 효과(로그, 카운트, API 호출)가 있거나 무거운 처리인데 결과를 두 번 이상 쓴다면, 한 번 .toList() 해서 리스트로 들고 있으세요. 플러터에서 필드에 담아 둔 where(...) 를 build() 에서 읽으면 다시 그릴 때마다 거르기가 돌아갑니다.

흔한 실수: 순회 중 리스트 변경과 const 리스트

for-in 안에서 요소 지우기

상황. 과락 점수를 지우고 싶다.

final scores = [90, 40, 75, 30, 60];
for (final s in scores) {
  if (s < 50) scores.remove(s);
}

실제로 일어나는 일.

Unhandled exception:
Concurrent modification during iteration: Instance(length:4) of '_GrowableList'.
#0      ListIterator.moveNext (dart:_internal/iterable.dart:365:7)
#1      main (~/demo/modify.dart:20:19)

첫 remove(40) 은 성공했고(길이가 4가 됨), 다음 바퀴에서 루프의 이터레이터가 리스트가 바뀐 것을 알아채고 ConcurrentModificationError 를 던졌습니다. 여기서 "Concurrent" 는 스레드 이야기가 아니라 "도는 도중에" 라는 뜻입니다. 자바의 ConcurrentModificationException 과 같은 개념이지만, 분석기는 이것을 잡지 못합니다.

해결. 세 가지, 모두 [90, 75, 60] 을 출력했습니다.

final a = [...scores]..removeWhere((s) => s < 50);     // change in place
final b = scores.where((s) => s >= 50).toList();       // build a new list
final c = [...scores];
for (final s in [...c]) {                              // loop over a copy
  if (s < 50) c.remove(s);
}

가장 읽기 쉬운 것은 removeWhere 입니다. 플러터 상태 코드에서는 새 리스트를 만드는 b 가 대개 더 낫습니다. setState 와 대부분의 상태 관리 도구는 고친 객체보다 새 객체를 전제로 하기 때문입니다.

const 리스트에 쓰기

const defaultTabs = ['home', 'search'];

void main() {
  final fixed = List.filled(3, 0);                  // fixed length
  final view = List.unmodifiable(['a', 'b']);
  final tabs = [...defaultTabs, 'profile'];         // a growable copy
  tabs.add('settings');
  print(tabs);
  fixed[0] = 7;
  print(fixed);
  // then each of these, inside try/catch:
  // fixed.add(1); view.add('c'); defaultTabs[0] = 'feed'; defaultTabs.add('profile');
}
[home, search, profile, settings]
[7, 0, 0]
UnsupportedError: Unsupported operation: Cannot add to a fixed-length list
UnsupportedError: Unsupported operation: Cannot add to an unmodifiable list
UnsupportedError: Unsupported operation: Cannot modify an unmodifiable list
UnsupportedError: Unsupported operation: Cannot add to an unmodifiable list

네 개 모두 분석기가 아무 말 없이 컴파일됩니다. 타입은 여전히 add 를 가진 List<String> 이기 때문입니다. 오류는 실행할 때 처음 나옵니다. 교훈은 둘입니다. const(와 List.unmodifiable)는 변수만이 아니라 내용까지 못 바꾸게 하고, List.filled(3, 0) 은 고정 길이 리스트를 돌려줍니다(늘리고 싶다면 List.filled(3, 0, growable: true)). 자바의 List.of(...) 가 add 에서 UnsupportedOperationException 을 던지는 것과 같은 함정입니다. const 기본값을 바꾸고 싶다면 [...defaultTabs] 로 복사합니다.

실측: Set 과 List 검색, for 루프와 fold

두 벤치마크 모두 dart compile exe(플러터 릴리스 빌드와 같은 AOT)로 컴파일한 것과 dart run(JIT) 둘 다로 실행했습니다. 데이터는 Random(42) 로 만들었습니다. 각 값은 7라운드 중 최솟값이고, AOT 는 따로 돌린 3번의 중앙값입니다. 파일은 dart:math 와 Stopwatch 만 쓰는 bench.dart 하나입니다.

검색 10,000번(약 절반이 적중):

요소 수 List.contains (AOT) Set.contains (AOT) List.contains (JIT) Set.contains (JIT)
10 0.27 ms 0.14 ms 0.15~0.24 ms 0.14~0.15 ms
1,000 19.0 ms 0.14 ms 5.1 ms 0.14~0.15 ms
100,000 1,920 ms 0.16 ms 495~498 ms 0.16~0.17 ms
막대그래프. 정수 1,000개에서 10,000번 검색하면 List.contains 는 AOT 실행 파일에서 19.0밀리초, JIT 에서 5.1밀리초였고, Set.contains 는 둘 다 0.14밀리초였다.
int 1,000개에 대한 검색 10,000회(seed 42, 7라운드 중 최솟값). AOT exe: List.contains 19.0 ms, Set.contains 0.14 ms. JIT(dart run): 5.1 ms 와 0.14 ms. Dart 3.11.5, Apple M1 Pro.

List.contains 는 요소를 하나씩 비교하므로 크기에 비례해 느려지고, Set.contains 는 해시를 써서 거의 일정했습니다. 10만 개에서는 AOT 기준 약 12,000배 차이입니다. 10개라면 2배 차이뿐이니, 작은 리스트에서는 읽기 쉬운 쪽을 고르면 됩니다.

예상하지 못한 결과도 있었습니다. List.contains 는 JIT 가 AOT 보다 약 3.8배 빨랐습니다(5.1 ms 대 19.0 ms, 여러 번 돌려도 같은 경향). 원인을 프로파일링하지는 않았습니다. 가장 그럴듯한 설명은, JIT 는 실행 중 실제로 본 타입(여기서는 int 뿐)을 전제로 최적화할 수 있고, AOT 는 어떤 요소 타입이든 동작하는 코드를 만들어야 한다는 것입니다. "AOT 가 항상 빠르다" 는 법칙이 아닙니다. AOT 의 장점은 빠른 시작(1강: 17 ms 대 199 ms)과 예측 가능성이고, 그래서 플러터가 릴리스에 AOT 를 씁니다.

int 1,000만 개 합계(모든 방식에서 합계 494,987,162):

방식 AOT JIT
for (var i = 0; i < data.length; i++) 6.5 ms 6.6 ms
for (final x in data) 6.4 ms 6.6 ms
data.fold(0, (a, b) => a + b) 27.1 ms 52.2 ms
data.reduce((a, b) => a + b) 27.5 ms 52.6 ms
막대그래프. 정수 1,000만 개 합계는 AOT 실행 파일에서 인덱스 for 루프 6.5밀리초, for-in 6.4밀리초, fold 27.1밀리초였다. JIT 에서는 for 루프 6.6밀리초, fold 52.2밀리초였다.
int 1,000만 개 합계(seed 42, 7라운드 중 최솟값). AOT exe: for 6.5 ms, for-in 6.4 ms, fold 27.1 ms. JIT: for 6.6 ms, fold 52.2 ms. Dart 3.11.5, Apple M1 Pro.

fold 는 클로저를 1,000만 번 호출하므로 AOT 에서 평범한 루프의 약 4배, JIT 에서 약 8배 느렸습니다. 1,000만 개에서 20 ms 차이이고, 화면에 보이는 30개라면 의미 없는 차이입니다. 읽기 좋으면 fold, 측정해서 무겁다고 확인된 핫 패스에서는 루프를 쓰면 됩니다.

한계와 버전 메모

  • 모든 실행은 Dart 3.11.5, Apple M1 Pro 에서만 했습니다. 휴대폰 실기기에서는 절댓값이 다르고, AOT/JIT 차이도 SDK 버전에 따라 바뀔 수 있습니다.
  • 벤치마크는 list.contains 를 티어오프(함수 값)로 넘기므로, 두 열 모두 검색마다 함수 호출 한 번이 더해집니다. Set 의 0.14 ms 중 일부는 그 오버헤드입니다.
  • 위에서 쓴 for (final MapEntry(:key, :value) in map.entries) 패턴은 Dart 3.0 이상이 필요합니다(레코드와 패턴, 4강에서 다룹니다). 직접 확인했습니다. 파일 첫 줄에 // @dart=2.19 를 쓰면 This requires the 'records' language feature to be enabled. ... - experiment_not_enabled 가 나오지만, 같은 파일의 collection if/for 와 스프레드는 통과합니다(이들은 Dart 2.3 부터인데, 3.11.5 가 받아 주는 최저 버전이 2.12 라서 2.2 로는 시험하지 못했습니다).
  • ConcurrentModificationError 는 이터레이터의 다음 걸음에서 던져지므로, 리스트의 어디를 바꿨느냐에 따라 나타나는 방식이 다를 수 있습니다. 여기서는 실제로 돌린 경우를 보였습니다.

연습 문제

1. label('Pro plan', price: 9) 에 'Pro plan: 9 USD' 를 돌려주고, sale 이 true 면 ' (sale)' 을 붙이는 label(String name, {required int price, String currency = 'USD', bool sale = false}) 를 작성하세요.

2. check 는 몇 번, 두 결과 줄과 비교해 어떤 순서로 출력될까요?

final it = [1, 2, 3, 4].where((n) {
  print('check $n');
  return n > 2;
});
print('isEmpty: ${it.isEmpty}');
print('length: ${it.length}');

3. ['a', '', 'b', '', '', 'c'] 에서 빈 문자열을 모두, ConcurrentModificationError 없이 제자리에서 지우세요.

정답

1.

String label(String name, {required int price, String currency = 'USD', bool sale = false}) =>
    '$name: $price $currency${sale ? ' (sale)' : ''}';

label('Pro plan', price: 9) 는 Pro plan: 9 USD, label('Pro plan', price: 7, sale: true, currency: 'EUR') 는 Pro plan: 7 EUR (sale) 을 출력했습니다. 이름 있는 인자는 순서가 상관없습니다.

2. 7번. 실제 출력은 check 1, check 2, check 3, isEmpty: false, 이어서 check 1~check 4, length: 2. isEmpty 는 첫 일치(3)에서 멈추고, length 는 전부를 다시 훑습니다. where 결과는 지연이라 아무것도 저장하지 않기 때문입니다.

3. words.removeWhere((w) => w.isEmpty); 로 [a, b, c] 가 출력됐습니다.

다음 강좌

다음 강좌는 함수에서 한 걸음 나아가 직접 정의하는 타입을 다룹니다. 클래스, 생성자, 믹스인, 확장 메서드와, 플러터 개발자가 이들을 헷갈렸을 때 만나는 오류입니다.

자주 묻는 질문

where() 나 map() 안의 print 가 실행되지 않는 이유는?

where() 와 map() 은 지연 평가라서 할 일을 설명하는 Iterable 만 돌려주기 때문입니다. 콜백은 .toList(), .first, for-in 루프처럼 요소를 꺼낼 때 실행되고, 꺼낼 때마다 매번 다시 실행됩니다. 결과를 여러 번 쓸 거라면 한 번 .toList() 하세요.

"Concurrent modification during iteration" 오류는 어떻게 고치나요?

for-in 루프로 리스트를 도는 중에 그 리스트에 요소를 넣거나 뺐기 때문입니다. list.removeWhere(...) 를 쓰거나, where(...).toList() 로 새 리스트를 만들거나, 복사본([...list])을 돌면서 원본을 바꾸면 됩니다.

다트에서 Set.contains 가 List.contains 보다 정말 빠른가요?

요소가 어느 정도 많으면 그렇습니다. int 1,000개에서 10,000번 검색하면 AOT exe 기준 List.contains 19.0 ms, Set.contains 0.14 ms 였습니다(Dart 3.11.5, M1 Pro). 요소가 10개일 때는 약 2배 차이뿐이었습니다.

  • #다트
  • #초급
  • #함수
  • #컬렉션
  • #Iterable
  • #플러터

t.laboratory

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

운영자 소개 · 앱

↑