Dart 함수형 프로그래밍

이 문서 주변 탐색

주제 태그, 링크, 시리즈 흐름을 중심으로 옆으로 이동할 수 있습니다.

시리즈 흐름

이 문서는 아직 읽기 시리즈에 연결되지 않았습니다.

관련 문서

같이 읽을 만한 관련 문서가 아직 없습니다.

이 문서를 참조하는 문서

이 문서를 참조하는 다른 문서가 아직 없습니다.

1. 함수형 프로그래밍 기초

함수형 프로그래밍이란?

데이터를 변형(mutate)하지 않고, 새로운 데이터를 만들어내는 프로그래밍 패러다임임

// ❌ 명령형 (Imperative) - 데이터 변형
void addSuffixImperative(List<String> list) {
  for (int i = 0; i < list.length; i++) {
    list[i] = list[i] + '##';  // 원본 변경!
  }
}

// ✅ 함수형 (Functional) - 새 데이터 생성
List<String> addSuffixFunctional(List<String> list) {
  return list.map((x) => x + '##').toList();  // 새 리스트 반환
}

핵심 원칙

원칙

설명

불변성

원본 데이터 변경 안 함

순수 함수

같은 입력 → 항상 같은 출력

일급 함수

함수를 값처럼 전달, 반환

합성

작은 함수들 조합해서 복잡한 로직 구성

  • Dart는 OOP + 함수형 모두 지원하는 멀티패러다임 언어

  • 함수형 패턴은 코드를 예측 가능하고 테스트하기 쉽게 만듦

  • Flutter 상태 관리(Provider, Riverpod, Bloc)도 함수형 원칙 따름

2. 컬렉션 변환 (List ↔ Map ↔ Set)

List → Map (asMap)

void main() {
  List<String> list = ['a', 'b', 'c', 'd'];

  // asMap() - 인덱스가 키가 됨
  Map<int, String> map = list.asMap();

  print(map);        // {0: a, 1: b, 2: c, 3: d}
  print(map.keys);   // (0, 1, 2, 3)
  print(map.values); // (a, b, c, d)

  // keys, values는 Iterable이라서 List로 변환 필요
  print(map.keys.toList());   // [0, 1, 2, 3]
  print(map.values.toList()); // [a, b, c, d]
}

List → Set (중복 제거)

void main() {
  List<String> list = ['1', '2', '3', '3', '2', '1'];

  // 방법 1: toSet()
  Set<String> set1 = list.toSet();
  print(set1);  // {1, 2, 3}

  // 방법 2: Set.from()
  Set<String> set2 = Set.from(list);
  print(set2);  // {1, 2, 3}

  // Set → List
  List<String> uniqueList = set1.toList();
  print(uniqueList);  // [1, 2, 3]
}

Map 변환

void main() {
  Map<String, String> map = {
    '1': '11',
    '2': '22',
    '3': '33',
  };

  // map.map() - MapEntry를 반환해야 함
  final result = map.map(
    (key, value) => MapEntry('oh-$key', 'my-$value'),
  );

  print(result);  // {oh-1: my-11, oh-2: my-22, oh-3: my-33}

  // keys나 values만 변환
  final keys = map.keys.map((x) => 'hello $x').toList();
  print(keys);  // [hello 1, hello 2, hello 3]

  final values = map.values.map((x) => '$x!').toList();
  print(values);  // [11!, 22!, 33!]
}
  • asMap()인덱스를 키로 사용하는 Map 만듦

  • toSet()이랑 Set.from()동일한 결과

  • Set도 map(), where()함수형 메서드 다 지원

  • Map의 map()MapEntry 반환해야 해서 문법이 다름

3. map - 변환하기

기본 사용법

void main() {
  List<String> list = ['1', '2', '3'];

  // map으로 각 요소 변환
  final newList = list.map((x) {
    return x + '##';
  });

  print(newList);  // (1##, 2##, 3##) ← 괄호 주목!

  // 화살표 함수로 간결하게
  final newList2 = list.map((x) => '$x###');

  print(newList2); // (1###, 2###, 3###)

  // ⚠️ 둘은 다른 객체!
  print(newList == newList2);  // false
}

실전 예시: 문자열 파싱

void main() {
  String number = '12345';

  // 각 숫자를 이미지 파일명으로 변환
  final parsed = number
      .split('')                    // ['1', '2', '3', '4', '5']
      .map((x) => '$x.jpg')         // (1.jpg, 2.jpg, ...)
      .toList();                    // [1.jpg, 2.jpg, ...]

  print(parsed);  // [1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg]
}

인덱스가 필요할 때

void main() {
  List<String> fruits = ['사과', '바나나', '오렌지'];

  // 방법 1: asMap + entries
  final indexed1 = fruits.asMap().entries.map(
    (entry) => '${entry.key}: ${entry.value}',
  ).toList();

  print(indexed1);  // [0: 사과, 1: 바나나, 2: 오렌지]

  // 방법 2: extension 활용 (직접 만들거나 collection 패키지)
  // final indexed2 = fruits.mapIndexed((i, x) => '$i: $x').toList();
}

4. where - 필터링하기

기본 사용법

void main() {
  List<int> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  // 짝수만 필터링
  final evens = numbers.where((x) => x % 2 == 0);

  print(evens);          // (2, 4, 6, 8, 10)
  print(evens.toList()); // [2, 4, 6, 8, 10]
}

복잡한 객체 필터링

void main() {
  List<Map<String, String>> people = [
    {'name': '1번', 'group': '그룹A'},
    {'name': '2번', 'group': '그룹B'},
    {'name': '3번', 'group': '그룹A'},
    {'name': '4번', 'group': '그룹C'},
    {'name': '5번', 'group': '그룹A'},
  ];

  // 그룹A인 사람들만
  final groupA = people.where((x) => x['group'] == '그룹A');

  print(groupA.toList());
  // [{name: 1번, group: 그룹A}, {name: 3번, group: 그룹A}, {name: 5번, group: 그룹A}]

  // 이름에 '1' 포함된 사람
  final hasOne = people.where((x) => x['name']!.contains('1'));

  print(hasOne.toList());  // [{name: 1번, group: 그룹A}]
}

관련 메서드들

void main() {
  List<int> numbers = [1, 2, 3, 4, 5];

  // firstWhere - 조건에 맞는 첫 번째 요소
  final first = numbers.firstWhere((x) => x > 3);
  print(first);  // 4

  // firstWhere with orElse - 없으면 기본값
  final notFound = numbers.firstWhere(
    (x) => x > 10,
    orElse: () => -1,
  );
  print(notFound);  // -1

  // lastWhere - 조건에 맞는 마지막 요소
  final last = numbers.lastWhere((x) => x < 4);
  print(last);  // 3

  // any - 하나라도 만족하면 true
  print(numbers.any((x) => x > 4));   // true

  // every - 모두 만족하면 true
  print(numbers.every((x) => x > 0)); // true
}
  • where는 JavaScript의 filter랑 동일함

  • firstWherefind, anysome, everyevery랑 같음

  • whereIterable 반환하니까 필요하면 toList() 호출해야 함

5. reduce와 fold - 접기

reduce - 리스트를 하나의 값으로

void main() {
  List<int> numbers = [1, 2, 3, 4, 5];

  // reduce: 첫 번째 요소부터 시작
  final sum = numbers.reduce((prev, curr) => prev + curr);
  print(sum);  // 15

  // 동작 과정:
  // 1 + 2 = 3
  // 3 + 3 = 6
  // 6 + 4 = 10
  // 10 + 5 = 15

  // 최댓값 찾기
  final max = numbers.reduce((a, b) => a > b ? a : b);
  print(max);  // 5

  // 문자열 합치기
  List<String> words = ['Hello', 'World', 'Dart'];
  final sentence = words.reduce((a, b) => '$a $b');
  print(sentence);  // Hello World Dart
}

⚠️ reduce의 제한

void main() {
  List<int> numbers = [1, 2, 3, 4, 5];

  // ❌ reduce는 반환 타입이 리스트 요소 타입과 같아야 함!
  // final asString = numbers.reduce((a, b) => '$a$b');  // 에러!

  // JavaScript에서는 가능하지만 Dart에서는 불가:
  // [1,2,3].reduce((acc, x) => acc + '-' + x, '')  // JS는 됨
}

fold - 초기값과 다른 타입 반환

void main() {
  List<int> numbers = [1, 2, 3, 4, 5];

  // fold: 초기값을 지정할 수 있음
  final sum = numbers.fold(0, (prev, curr) => prev + curr);
  print(sum);  // 15

  // 다른 타입으로 변환 가능!
  final asString = numbers.fold<String>(
    '',
    (prev, curr) => prev.isEmpty ? '$curr' : '$prev-$curr',
  );
  print(asString);  // 1-2-3-4-5

  // 초기값이 있어서 빈 리스트도 안전
  List<int> empty = [];
  // empty.reduce((a, b) => a + b);  // ❌ 에러! (빈 리스트)
  final safeSum = empty.fold(0, (a, b) => a + b);  // ✅ 0
}

fold 실전 예시

void main() {
  List<Map<String, dynamic>> items = [
    {'name': '사과', 'price': 1000, 'qty': 3},
    {'name': '바나나', 'price': 500, 'qty': 5},
    {'name': '오렌지', 'price': 800, 'qty': 2},
  ];

  // 총 금액 계산
  final totalPrice = items.fold<int>(
    0,
    (sum, item) => sum + (item['price'] as int) * (item['qty'] as int),
  );
  print('총 금액: $totalPrice원');  // 총 금액: 6100원

  // 짝수/홀수 분류
  List<int> numbers = [1, 2, 3, 4, 5, 6, 7, 8];

  final grouped = numbers.fold<Map<String, List<int>>>(
    {'even': [], 'odd': []},
    (acc, n) {
      if (n % 2 == 0) {
        acc['even']!.add(n);
      } else {
        acc['odd']!.add(n);
      }
      return acc;
    },
  );
  print(grouped);  // {even: [2, 4, 6, 8], odd: [1, 3, 5, 7]}
}
  • reduce: 리스트 요소랑 같은 타입만 반환 가능, 빈 리스트 불가

  • fold: 초기값 지정 가능, 다른 타입 반환 가능, 빈 리스트 안전

  • JavaScript의 reduce는 Dart의 fold랑 더 비슷함 (초기값 지정 가능)

  • 복잡한 집계 로직에는 항상 fold 쓰면 됨

6. Iterable vs List - ()와 []의 차이

왜 괄호()랑 대괄호[]가 다를까?

void main() {
  List<String> list = ['1', '2', '3'];

  // map은 Iterable을 반환 (게으른 평가)
  final mapped = list.map((x) => '$x##');
  print(mapped);         // (1##, 2##, 3##) ← 괄호!
  print(mapped.runtimeType);  // MappedListIterable<String, String>

  // toList()로 List로 변환
  final asList = mapped.toList();
  print(asList);         // [1##, 2##, 3##] ← 대괄호!
  print(asList.runtimeType);  // List<String>
}

Iterable의 특징 (게으른 평가)

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

  // map은 즉시 실행 안 됨!
  var mapped = numbers.map((x) {
    print('변환 중: $x');
    return x * 2;
  });

  print('--- map 호출 완료 ---');
  // 아직 "변환 중" 출력 안 됨!

  print('--- toList 호출 ---');
  var result = mapped.toList();
  // 이제야 "변환 중" 출력됨!

  // 출력:
  // --- map 호출 완료 ---
  // --- toList 호출 ---
  // 변환 중: 1
  // 변환 중: 2
  // 변환 중: 3
  // 변환 중: 4
  // 변환 중: 5
}

언제 toList()를 호출해야 할까?

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

  // ✅ toList() 필요한 경우
  // 1. 인덱스 접근이 필요할 때
  var list = numbers.map((x) => x * 2).toList();
  print(list[0]);  // 2

  // 2. length를 효율적으로 알아야 할 때
  print(list.length);  // 5

  // 3. 결과를 여러 번 사용할 때
  for (var i = 0; i < 3; i++) {
    for (var item in list) {
      print(item);
    }
  }

  // ⚠️ toList() 없이 Iterable 재사용하면 매번 재계산!
  var iterable = numbers.map((x) {
    print('계산!');
    return x * 2;
  });

  // 두 번 순회하면 두 번 계산
  iterable.forEach(print);  // 계산! 5번
  iterable.forEach(print);  // 또 계산! 5번

  // ✅ toList() 불필요한 경우
  // 1. 체이닝 중간 단계
  var result = numbers
      .map((x) => x * 2)     // toList 불필요
      .where((x) => x > 5)   // toList 불필요
      .toList();             // 마지막에만!

  // 2. 한 번만 순회할 때
  numbers.map((x) => x * 2).forEach(print);
}
  • () = Iterable (게으른 평가, lazy evaluation)

  • [] = List (즉시 평가, eager evaluation)

  • Iterable은 순회할 때마다 재계산

  • 성능상 체이닝 중간에는 toList 불필요, 마지막에만 호출하면 됨

  • 결과를 여러 번 사용하거나 인덱스 접근 필요하면 toList() 해야 함

7. Spread 연산자 (...)

기본 사용법

void main() {
  var list1 = [1, 2, 3];
  var list2 = [4, 5, 6];

  // Spread 연산자로 합치기
  var combined = [...list1, ...list2];
  print(combined);  // [1, 2, 3, 4, 5, 6]

  // 중간에 요소 추가
  var withMiddle = [...list1, 100, ...list2];
  print(withMiddle);  // [1, 2, 3, 100, 4, 5, 6]
}

짝수/홀수 분리 예시

void main() {
  var numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  var even = numbers.where((x) => x % 2 == 0);
  var odd = numbers.where((x) => x % 2 == 1);

  // ❌ 중첩 리스트가 됨
  print([even, odd]);
  // [(2, 4, 6, 8, 10), (1, 3, 5, 7, 9)]

  // ✅ Spread로 펼치기
  print([...even, ...odd]);
  // [2, 4, 6, 8, 10, 1, 3, 5, 7, 9]

  // toList() 없이도 Spread 사용 가능!
  // Spread가 Iterable을 자동으로 펼침
}

Null-aware Spread (...?)

void main() {
  List<int>? nullableList;
  var list = [1, 2, 3];

  // ❌ null이면 에러!
  // var combined = [...nullableList, ...list];

  // ✅ null-aware spread
  var safeCombined = [...?nullableList, ...list];
  print(safeCombined);  // [1, 2, 3]

  // null이 아니면 정상 동작
  nullableList = [0];
  var result = [...?nullableList, ...list];
  print(result);  // [0, 1, 2, 3]
}

Set과 Map에도 사용 가능

void main() {
  // Set spread
  var set1 = {1, 2, 3};
  var set2 = {3, 4, 5};
  var combinedSet = {...set1, ...set2};
  print(combinedSet);  // {1, 2, 3, 4, 5} - 중복 제거됨

  // Map spread
  var map1 = {'a': 1, 'b': 2};
  var map2 = {'c': 3, 'd': 4};
  var combinedMap = {...map1, ...map2};
  print(combinedMap);  // {a: 1, b: 2, c: 3, d: 4}

  // Map 덮어쓰기
  var override = {...map1, 'a': 100};  // 'a' 값 변경
  print(override);  // {a: 100, b: 2}
}

Collection if와 for

void main() {
  bool showExtra = true;

  // Collection if
  var items = [
    'item1',
    'item2',
    if (showExtra) 'extraItem',
  ];
  print(items);  // [item1, item2, extraItem]

  // Collection for
  var numbers = [1, 2, 3];
  var doubled = [
    for (var n in numbers) n * 2,
  ];
  print(doubled);  // [2, 4, 6]

  // 조합
  var complex = [
    'header',
    for (var i = 0; i < 3; i++)
      if (i.isEven) 'even-$i',
    'footer',
  ];
  print(complex);  // [header, even-0, even-2, footer]
}
  • Spread(...)는 새로운 컬렉션 생성함 (불변성 유지)

  • ...?null 안전한 spread임

  • Collection if/for로 선언적으로 컬렉션 구성 가능

  • JavaScript의 spread랑 동일하게 동작함

8. 기타 유용한 메서드

take와 skip

void main() {
  var numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  // take - 처음 N개
  print(numbers.take(3).toList());  // [1, 2, 3]

  // skip - 처음 N개 건너뛰기
  print(numbers.skip(7).toList());  // [8, 9, 10]

  // 조합: 페이지네이션
  int page = 2;
  int pageSize = 3;
  var pageItems = numbers.skip((page - 1) * pageSize).take(pageSize);
  print(pageItems.toList());  // [4, 5, 6]
}

expand (flatMap)

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

  // expand로 평탄화
  var flat = nested.expand((x) => x);
  print(flat.toList());  // [1, 2, 3, 4, 5, 6]

  // 각 요소를 여러 개로 확장
  var numbers = [1, 2, 3];
  var repeated = numbers.expand((x) => [x, x, x]);
  print(repeated.toList());  // [1, 1, 1, 2, 2, 2, 3, 3, 3]
}

sort와 sorted

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

  // sort - 원본 변경 (비함수형!)
  // numbers.sort();

  // sorted 패턴 - 새 리스트 반환 (함수형)
  var sorted = [...numbers]..sort();
  print(sorted);      // [1, 1, 2, 3, 4, 5, 6, 9]
  print(numbers);     // [3, 1, 4, 1, 5, 9, 2, 6] - 원본 유지

  // 커스텀 정렬
  var people = [
    {'name': '철수', 'age': 25},
    {'name': '영희', 'age': 22},
    {'name': '민수', 'age': 30},
  ];

  var byAge = [...people]..sort((a, b) =>
    (a['age'] as int).compareTo(b['age'] as int)
  );
}

reversed

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

  // reversed - Iterable 반환
  print(numbers.reversed);           // (5, 4, 3, 2, 1)
  print(numbers.reversed.toList());  // [5, 4, 3, 2, 1]

  // 원본은 변경 안 됨
  print(numbers);  // [1, 2, 3, 4, 5]
}

9. 체이닝과 조합

메서드 체이닝

void main() {
  var data = [
    {'name': '사과', 'price': 1000, 'inStock': true},
    {'name': '바나나', 'price': 500, 'inStock': true},
    {'name': '오렌지', 'price': 800, 'inStock': false},
    {'name': '포도', 'price': 2000, 'inStock': true},
    {'name': '수박', 'price': 15000, 'inStock': true},
  ];

  // 체이닝으로 복잡한 로직 표현
  var result = data
      .where((item) => item['inStock'] == true)        // 재고 있는 것만
      .where((item) => (item['price'] as int) < 2000)  // 2000원 미만
      .map((item) => item['name'])                      // 이름만 추출
      .toList();

  print(result);  // [사과, 바나나]
}

가독성 높이기

void main() {
  var users = [/* ... */];

  // ❌ 한 줄로 길게
  var result = users.where((u) => u.isActive).map((u) => u.name).where((n) => n.startsWith('A')).toList();

  // ✅ 줄바꿈으로 가독성 향상
  var betterResult = users
      .where((u) => u.isActive)
      .map((u) => u.name)
      .where((n) => n.startsWith('A'))
      .toList();

  // ✅ 중간 변수로 의미 부여
  var activeUsers = users.where((u) => u.isActive);
  var names = activeUsers.map((u) => u.name);
  var filteredNames = names.where((n) => n.startsWith('A')).toList();
}

10. 실전 패턴

패턴 1: 데이터 변환 파이프라인

class Product {
  final String name;
  final double price;
  final String category;

  Product(this.name, this.price, this.category);
}

void main() {
  var products = [
    Product('아이폰', 1200000, '전자기기'),
    Product('갤럭시', 1000000, '전자기기'),
    Product('에어팟', 200000, '전자기기'),
    Product('책상', 300000, '가구'),
    Product('의자', 150000, '가구'),
  ];

  // 전자기기의 총 가격
  var totalElectronics = products
      .where((p) => p.category == '전자기기')
      .map((p) => p.price)
      .fold(0.0, (sum, price) => sum + price);

  print('전자기기 총액: $totalElectronics원');  // 2400000원

  // 카테고리별 그룹화
  var byCategory = products.fold<Map<String, List<Product>>>(
    {},
    (map, product) {
      map.putIfAbsent(product.category, () => []);
      map[product.category]!.add(product);
      return map;
    },
  );

  print(byCategory.keys);  // (전자기기, 가구)
}

패턴 2: 중복 제거와 정렬

void main() {
  var tags = ['dart', 'flutter', 'dart', 'mobile', 'flutter', 'web'];

  // 중복 제거 + 정렬
  var uniqueSorted = tags.toSet().toList()..sort();
  print(uniqueSorted);  // [dart, flutter, mobile, web]

  // 또는 함수형으로
  var result = [...{...tags}]..sort();
  print(result);  // [dart, flutter, mobile, web]
}

패턴 3: 조건부 리스트 생성

void main() {
  bool isAdmin = true;
  bool isPremium = false;

  var menuItems = [
    'Home',
    'Profile',
    if (isAdmin) 'Admin Panel',
    if (isPremium) 'Premium Features',
    'Settings',
  ];

  print(menuItems);  // [Home, Profile, Admin Panel, Settings]
}

패턴 4: 객체 복사와 업데이트 (불변 패턴)

class User {
  final String name;
  final int age;
  final List<String> hobbies;

  const User({
    required this.name,
    required this.age,
    required this.hobbies,
  });

  // copyWith 패턴
  User copyWith({
    String? name,
    int? age,
    List<String>? hobbies,
  }) {
    return User(
      name: name ?? this.name,
      age: age ?? this.age,
      hobbies: hobbies ?? this.hobbies,
    );
  }
}

void main() {
  var user = User(name: '모리', age: 25, hobbies: ['독서', '코딩']);

  // 원본 변경 없이 새 객체 생성
  var olderUser = user.copyWith(age: 26);
  var newHobbies = user.copyWith(hobbies: [...user.hobbies, '운동']);

  print(user.age);          // 25 (원본 유지)
  print(olderUser.age);     // 26
  print(newHobbies.hobbies); // [독서, 코딩, 운동]
}

🤖

본 글은 AI의 도움을 받아 작성되었습니다.

읽기 시리즈

현재 위치를 확인하고, 흐름을 따라 바로 다음 읽기로 이어갈 수 있습니다.

Reading Series

Dart
0편

현재 읽는 문서

Dart 함수형 프로그래밍

Dart 언어에 대해서 알아보기

이전 문서

시리즈의 시작 문서입니다.

다음 문서

시리즈의 마지막 문서입니다.