Flutter와 Dart로 애플리케이션을 개발하는 여정은 곧 데이터와의 끊임없는 소통 과정입니다. 서버에서 받아온 JSON, 사용자가 입력한 정보, 기기에 저장된 설정값 등 모든 것이 데이터의 연속입니다. 우리는 이 데이터들을 List, Map, Set과 같은 컬렉션에 담아 가공하고 화면에 보여줍니다. Dart가 기본적으로 제공하는 컬렉션 API는 충분히 강력하지만, 실무 프로젝트의 복잡한 요구사항과 마주하면 이내 한계에 부딪히는 순간이 찾아옵니다.
예를 들어, 서버에서 받은 사용자 목록(List<User>)의 순서만 바뀌었을 뿐 내용은 동일한데, 상태 관리 프레임워크가 이를 '다른 상태'로 인식하여 화면 전체를 불필요하게 다시 그리는 상황을 겪어보셨나요? 혹은 거래 내역 리스트를 '오늘', '어제', '이번 주'와 같이 유연한 기준으로 그룹화하기 위해 몇십 줄에 달하는 for 루프와 if-else 문을 작성하며 시간을 보낸 경험은 없으신가요? 이런 문제들은 개발자의 생산성을 저하시키고, 코드의 가독성을 해치며, 잠재적인 버그의 온상이 되기도 합니다.
바로 이러한 개발자들의 고충을 해결하기 위해 Dart 언어를 개발하는 바로 그 팀이 직접 만들고 관리하는 공식 보물 상자, 'collection' 패키지가 존재합니다. 이 패키지는 단순히 몇 가지 유틸리티 함수를 모아놓은 것이 아닙니다. Dart의 컬렉션 처리 방식에 대한 깊은 이해를 바탕으로, 데이터 처리의 패러다임을 바꿀 수 있는 강력하고 정교한 도구들을 제공합니다. 이 패키지를 제대로 활용하면 얻을 수 있는 이점은 명확합니다.
- 압도적인 코드 가독성:
for,if, 임시 변수로 뒤섞인 명령형 코드를 "무엇을 원하는지" 명확히 드러내는 선언적 코드로 탈바꿈시킵니다. - 보일러플레이트 코드 박멸: 데이터 비교, 그룹화, 정렬 등 반복적으로 작성하던 로직을 단 한 줄의 함수 호출로 대체하여 핵심 비즈니스 로직에만 집중할 수 있게 해줍니다. `
- 최적화된 성능: Dart 전문가들이 구현한 효율적인 알고리즘(예: 안정적인 정렬을 위한 Merge Sort)을 사용하여, 어설프게 직접 구현하는 것보다 월등한 성능을 보장합니다.
- 최고 수준의 안정성: Dart 언어의 발전과 함께 업데이트되며, null safety를 완벽하게 지원하고 미래의 언어 변화에도 유연하게 대응할 수 있는 신뢰성을 제공합니다.
이 글에서는 collection 패키지가 제공하는 핵심 기능들을 단순히 소개하는 것을 넘어, 각 기능이 실제 Flutter/Dart 개발 현장에서 어떤 문제를 해결하고, 어떻게 코드를 혁신하는지 깊이 있게 파고들 것입니다. 이 글을 끝까지 정독하신다면, 여러분의 데이터 처리 코드가 얼마나 더 간결하고, 효율적이며, 우아해질 수 있는지 명확히 깨닫고 '전문가' 수준의 데이터 핸들링 기술을 갖추게 될 것입니다.
프로젝트의 필수품, collection 패키지 설치하기
모든 위대한 여정은 첫걸음부터 시작됩니다. 여러분의 프로젝트에 collection 패키지의 강력한 힘을 불어넣는 것은 매우 간단합니다. 프로젝트의 루트 디렉토리에서 터미널을 열고 다음 명령어를 실행하세요.
flutter pub add collection
이 명령어는 pubspec.yaml 파일을 자동으로 업데이트하고 패키지를 다운로드합니다. 수동으로 추가하고 싶다면, pubspec.yaml 파일의 dependencies 섹션에 아래와 같이 직접 추가할 수도 있습니다.
dependencies:
flutter:
sdk: flutter
# pub.dev에서 최신 버전을 확인하고 적용하는 것을 권장합니다.
collection: ^1.18.0
설치가 완료되었다면, 패키지를 사용하고자 하는 Dart 파일의 최상단에 마법의 주문을 외워주세요.
import 'package:collection/collection.dart';
이제 모든 준비는 끝났습니다. collection 패키지가 펼쳐 보이는 데이터 처리의 신세계로 함께 떠나보겠습니다.
1. 동등성 비교의 혁명: 왜 `list1 == list2`는 항상 false일까?
Dart를 처음 접하는 개발자들이 가장 흔하게 마주하는 함정 중 하나가 바로 컬렉션의 동등성 비교입니다. 직관적으로 생각했을 때, 내용물이 완전히 같은 두 개의 리스트는 '같다'고 판단되어야 할 것 같지만, Dart의 현실은 다릅니다.
근본적인 문제: 참조 동등성(Reference Equality)
아래 코드는 많은 개발자들을 좌절하게 만든 대표적인 예시입니다. 두 리스트와 두 맵은 각각 동일한 원소들을 가지고 있지만, == 연산자로 비교한 결과는 무정하게도 false를 반환합니다.
void main() {
var list1 = [1, 2, 3];
var list2 = [1, 2, 3];
print('list1 == list2: ${list1 == list2}'); // 출력: list1 == list2: false
var map1 = {'a': 1, 'b': 2};
var map2 = {'a': 1, 'b': 2};
print('map1 == map2: ${map1 == map2}'); // 출력: map1 == map2: false
}
이유는 간단합니다. Dart의 기본 == 연산자는 '참조 동등성'을 비교하기 때문입니다. 즉, 두 변수가 메모리 상의 정확히 동일한 객체(인스턴스)를 가리키고 있는지를 확인합니다. 위 코드에서 list1과 list2는 내용물은 같을지라도, 각각 별개의 메모리 공간에 할당된 서로 다른 리스트 객체입니다. 마치 내용물이 똑같은 두 권의 책이 서로 다른 책인 것과 같은 이치입니다.
이러한 동작 방식은 Flutter 개발에서 심각한 문제를 야기할 수 있습니다. 특히 상태 관리와 위젯 리빌드(rebuild) 과정에서 그렇습니다. 예를 들어, Riverpod이나 BLoC 같은 상태 관리 라이브러리는 이전 상태와 새로운 상태를 비교하여 변경이 있을 때만 UI를 업데이트합니다. 만약 상태 객체 내부에 리스트가 있고, 이 리스트의 내용 변경 없이 새로운 리스트 객체를 생성하여 상태를 업데이트한다면, 상태 관리자는 내용이 동일함에도 불구하고 '상태가 변경되었다'고 오해하여 불필요한 위젯 리빌드를 유발합니다. 이는 앱 성능 저하의 주범이 됩니다.
구원 투수: `collection` 패키지의 Equality 클래스
이처럼 까다로운 내용 동등성(Deep Equality) 비교를 위해 collection 패키지는 다양한 Equality 클래스를 제공합니다. 이 클래스들은 컬렉션의 껍데기(참조)가 아닌, 그 안의 내용물 하나하나를 비교하여 진정한 의미의 '같음'을 판단해 줍니다.
ListEquality: 리스트의 원소들을 순서대로 하나씩 비교합니다.SetEquality: 셋(Set)의 원소들을 비교합니다. 원소의 존재 여부만 중요하며 순서는 무관합니다.MapEquality: 맵(Map)의 키와 값을 비교합니다.UnorderedCollectionEquality: 리스트나 이터러블의 원소들을 순서에 상관없이 비교합니다.DeepCollectionEquality: 가장 강력한 해결사. 중첩된 컬렉션(리스트 안의 리스트, 맵 안의 리스트 등)까지 재귀적으로 탐색하며 모든 내용물을 비교합니다.
이제 이 클래스들을 사용하여 위의 문제를 해결해 보겠습니다.
import 'package:collection/collection.dart';
void main() {
var list1 = [1, 2, [3, 4]];
var list2 = [1, 2, [3, 4]];
// 기본 비교는 여전히 false
print('list1 == list2: ${list1 == list2}'); // 출력: false
// DeepCollectionEquality를 사용한 내용 기반 비교
const deepEquality = DeepCollectionEquality();
print('deepEquality.equals(list1, list2): ${deepEquality.equals(list1, list2)}'); // 출력: true
var map1 = {'a': 1, 'b': {'c': [5, 6]}};
var map2 = {'a': 1, 'b': {'c': [5, 6]}};
print('deepEquality.equals(map1, map2): ${deepEquality.equals(map1, map2)}'); // 출력: true
}
DeepCollectionEquality의 equals() 메서드를 사용하니 우리가 원했던 대로 true가 반환되었습니다. 이 클래스는 리스트 안의 리스트, 맵 안의 맵과 같이 아무리 복잡하게 중첩된 구조라도 끝까지 파고들어 각 원소의 동등성을 완벽하게 확인해 줍니다.
심화 학습: 순서가 중요하지 않다면? `UnorderedCollectionEquality`
사용자가 선택한 태그 목록을 관리하는 상황을 가정해 봅시다. ['flutter', 'dart']와 ['dart', 'flutter']는 본질적으로 같은 선택을 의미합니다. 이런 경우 순서까지 비교하는 ListEquality는 적합하지 않습니다. 이때 `UnorderedCollectionEquality`가 활약합니다.
import 'package:collection/collection.dart';
void main() {
var tags1 = ['flutter', 'dart'];
var tags2 = ['dart', 'flutter'];
// 순서를 고려하는 ListEquality
print('ListEquality: ${const ListEquality().equals(tags1, tags2)}'); // 출력: false
// 순서를 무시하는 UnorderedCollectionEquality
print('Unordered: ${const UnorderedCollectionEquality().equals(tags1, tags2)}'); // 출력: true
}
`equals()`와 영혼의 단짝, `hash()`
Equality 클래스들은 equals() 메서드뿐만 아니라 `hash()` 메서드도 함께 제공합니다. 이 두 메서드는 객체 지향 프로그래밍에서 매우 중요한 약속을 공유합니다: "equals()가 true인 두 객체는 `hash()`의 반환값도 반드시 같아야 한다."
해시 코드는 Set이나 Map의 키와 같이 해싱(hashing) 기반의 컬렉션에서 객체를 효율적으로 저장하고 검색하는 데 사용됩니다. Dart의 기본 List는 내용이 변경될 수 있으므로(mutable), 일관된 해시 코드를 제공하지 않아 Set의 원소나 Map의 키로 직접 사용할 수 없습니다.
하지만 collection 패키지의 Equality를 사용하면 이 제약을 우회할 수 있습니다. HashSet이나 HashMap을 생성할 때, 우리가 정의한 `equals`와 `hash` 로직을 주입하는 것입니다.
import 'dart:collection';
import 'package:collection/collection.dart';
void main() {
const equality = ListEquality<int>();
// ListEquality의 로직을 사용하는 HashSet 생성
var specializedSet = HashSet<List<int>>(
equals: equality.equals,
hashCode: equality.hash,
);
var listA = [1, 2];
var listB = [3, 4];
var listC = [1, 2]; // listA와 내용이 같지만 참조는 다름
specializedSet.add(listA);
specializedSet.add(listB);
specializedSet.add(listC);
// specializedSet은 ListEquality를 기준으로 동작하므로,
// 내용이 같은 listA와 listC를 동일한 객체로 간주하여 중복을 허용하지 않는다.
print(specializedSet); // 출력: {[1, 2], [3, 4]}
}
이처럼 Equality 기능은 단순한 값 비교를 넘어, Dart의 컬렉션 시스템을 더 깊고 올바르게 활용하여 상태 관리의 정확성을 높이고 불필요한 연산을 줄이는 핵심적인 역할을 수행합니다.
| 비교 방식 | 설명 | 주요 사용 사례 | `collection` 패키지 클래스 |
|---|---|---|---|
| 참조 동등성 (==) | 두 변수가 메모리 상의 동일한 인스턴스를 가리키는지 확인합니다. | 원시 타입(int, String) 비교, 동일 인스턴스 확인이 필요할 때. | (기본 연산자) |
| 얕은 동등성 (Shallow) | 컬렉션의 1단계 깊이 원소들만 비교합니다. 중첩된 컬렉션은 참조로 비교합니다. | 중첩 구조가 없는 단순한 컬렉션 비교 시. | ListEquality, SetEquality, MapEquality |
| 깊은 동등성 (Deep) | 중첩된 모든 컬렉션을 재귀적으로 탐색하여 가장 깊은 곳의 원소까지 값으로 비교합니다. | 복잡한 JSON 데이터, 중첩된 상태 객체 비교 등 대부분의 실무 상황. | DeepCollectionEquality |
| 순서 무관 동등성 | 컬렉션의 원소는 같지만 순서는 달라도 같다고 판단합니다. | 사용자 태그 선택, 권한 목록 비교 등 순서가 중요하지 않은 경우. | UnorderedCollectionEquality |
2. 데이터 그룹화의 마법사, groupBy 함수
애플리케이션 개발에서 평평한(flat) 데이터 목록을 특정 기준에 따라 여러 그룹으로 묶는 작업은 셀 수 없이 많이 발생합니다. 예를 들어, 연락처 목록을 초성별로, 거래 내역을 날짜별로, 상품 목록을 카테고리별로 그룹화하는 것이 대표적입니다. 이런 기능을 `collection` 패키지 없이 구현하려면 어떻게 해야 할까요?
문제점: 지루하고 오류에 취약한 수동 그룹화
아래는 상품 목록을 카테고리별로 그룹화하는 전형적인 '나쁜 예'입니다. 이 코드는 작동은 하지만 여러 문제점을 안고 있습니다.
class Product {
final String name;
final String category;
final double price;
Product(this.name, this.category, this.price);
@override
String toString() => 'Product($name, $category, \$$price)';
}
void main() {
var products = [
Product('Laptop', 'Electronics', 1200),
Product('Keyboard', 'Electronics', 75),
Product('Apple', 'Fruits', 1.5),
Product('Banana', 'Fruits', 0.5),
Product('T-shirt', 'Fashion', 25),
];
// [Before] 수동으로 그룹화하는 로직
var groupedProducts = <String, List<Product>>{};
for (var product in products) {
// 1. 맵에 해당 카테고리 키가 있는지 확인
if (!groupedProducts.containsKey(product.category)) {
// 2. 없으면 새로운 리스트를 생성하여 할당
groupedProducts[product.category] = [];
}
// 3. 해당 카테고리 리스트에 상품 추가
groupedProducts[product.category]!.add(product);
}
print(groupedProducts);
/* 출력:
{
Electronics: [Product(Laptop, Electronics, $1200.0), Product(Keyboard, Electronics, $75.0)],
Fruits: [Product(Apple, Fruits, $1.5), Product(Banana, Fruits, $0.5)],
Fashion: [Product(T-shirt, Fashion, $25.0)]
}
*/
}
이 코드의 문제점은 명확합니다. 로직이 장황하고, 그룹화 기준이 바뀔 때마다 코드를 수정해야 하며, 무엇보다 '어떤 데이터를 어떻게 그룹화한다'는 핵심 의도가 코드 여러 줄에 분산되어 있어 한눈에 파악하기 어렵습니다. 이런 코드는 유지보수를 어렵게 만듭니다.
해결책: 단 한 줄의 선언, groupBy
collection 패키지의 `groupBy` 함수는 이 모든 지저분한 과정을 마법처럼 단 한 줄로 압축합니다. `groupBy` 함수는 두 개의 인자를 받습니다: 그룹화할 대상인 이터러블(Iterable)과 각 원소에서 그룹화 기준이 될 키(key)를 추출하는 함수입니다. 결과물은 `Map<Key, List<Element>>` 형태로 반환됩니다.
import 'package:collection/collection.dart';
// Product 클래스는 위와 동일
void main() {
var products = [
Product('Laptop', 'Electronics', 1200),
Product('Keyboard', 'Electronics', 75),
Product('Apple', 'Fruits', 1.5),
Product('Banana', 'Fruits', 0.5),
Product('T-shirt', 'Fashion', 25),
];
// [After] groupBy 사용
final groupedProducts = groupBy(products, (Product p) => p.category);
print(groupedProducts);
// 출력 결과는 위와 완전히 동일합니다.
}
코드가 얼마나 간결하고 명확해졌는지 보세요! "products 리스트를 각 product의 category를 기준으로 그룹화하라"는 문장이 코드에 그대로 담겨 있습니다. 이것이 바로 선언적 프로그래밍의 힘입니다.
Flutter UI와 groupBy의 환상적인 시너지
groupBy의 진정한 가치는 Flutter UI를 구성할 때 드러납니다. 섹션 헤더가 있는 목록, 예를 들어 음악 앱의 앨범별 트랙 리스트나 이커머스 앱의 카테고리별 상품 목록을 `ListView`로 구현하는 경우를 생각해 봅시다. groupBy로 데이터를 미리 가공해두면 UI 코드가 놀라울 정도로 단순하고 직관적으로 변합니다.
아래는 groupBy를 활용하여 카테고리별 상품 목록을 만드는 가상의 Flutter 위젯 코드입니다.
// 가상의 Flutter 위젯 코드
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
// ... Product 클래스 정의 ...
class ProductListScreen extends StatelessWidget {
final List<Product> products;
const ProductListScreen({super.key, required this.products});
@override
Widget build(BuildContext context) {
// 1. 데이터를 렌더링하기 전에 카테고리별로 그룹화한다.
final groupedProducts = groupBy(products, (Product p) => p.category);
final categories = groupedProducts.keys.toList();
return Scaffold(
appBar: AppBar(title: const Text('상품 목록')),
body: ListView.builder(
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final itemsInCategory = groupedProducts[category]!;
return Card(
margin: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 2. 맵의 key를 사용하여 섹션 헤더를 만든다.
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
category,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Divider(),
// 3. 맵의 value(상품 리스트)를 사용하여 각 아이템을 만든다.
...itemsInCategory.map(
(product) => ListTile(
title: Text(product.name),
trailing: Text('\$${product.price.toStringAsFixed(2)}'),
),
),
],
),
);
},
),
);
}
}
이처럼 UI를 그리기 전에 데이터를 UI 구조에 맞는 형태로 가공하는 것은 매우 효율적인 접근 방식입니다. groupBy를 사용하면 복잡한 중첩 반복문이나 인덱스 계산 없이도 비즈니스 로직(데이터 그룹화)과 UI 렌더링 로직을 깔끔하게 분리할 수 있어 코드의 유지보수성과 테스트 용이성이 크게 향상됩니다.
3. 코드 다이어트의 비결: 강력한 컬렉션 확장 함수
collection 패키지는 Dart의 기본 `Iterable`(List, Set 등의 부모 클래스)에 수많은 확장 함수(extension methods)를 추가하여, 일상적인 데이터 처리 작업을 훨씬 더 간결하고 안전하게 만들어 줍니다. 마치 스위스 군용 칼처럼, 필요할 때마다 적절한 도구를 꺼내 쓸 수 있게 해줍니다.
예외 대신 null: 안전한 원소 탐색 `firstWhereOrNull`
Dart의 기본 `firstWhere` 메서드는 조건에 맞는 원소가 없을 경우 `StateError`라는 예외(exception)를 던집니다. 이는 프로그램의 흐름을 깨뜨릴 수 있어, 개발자는 항상 `try-catch` 블록으로 코드를 감싸거나, `where().isNotEmpty` 같은 번거로운 사전 검사를 수행해야 했습니다.
// [Before] 예외 처리가 강제되는 firstWhere
var numbers = [10, 20, 30];
int? foundNumber;
try {
foundNumber = numbers.firstWhere((n) => n > 50);
} on StateError {
foundNumber = null;
}
print(foundNumber); // null
collection 패키지의 `firstWhereOrNull` 확장 함수는 이러한 불편함을 완벽하게 해결합니다. 조건에 맞는 원소가 있으면 해당 원소를, 없으면 예외 대신 `null`을 반환하여 null-safe한 코드 작성을 돕습니다.
import 'package:collection/collection.dart';
var numbers = [10, 20, 30];
// [After] 간결하고 안전한 firstWhereOrNull
final foundNumber1 = numbers.firstWhereOrNull((n) => n > 50);
print(foundNumber1); // 출력: null
final foundNumber2 = numbers.firstWhereOrNull((n) => n > 15);
print(foundNumber2); // 출력: 20
단 하나의 원소만 존재해야 하는 `singleWhere`에 대응하는 `singleWhereOrNull`도 제공되어, 코드의 안전성과 가독성을 동시에 높일 수 있습니다.
인덱스가 필요할 때: `forEachIndexed`와 `mapIndexed`
반복문을 실행할 때 원소의 값과 함께 현재 인덱스가 필요한 경우는 매우 흔합니다. 보통은 아래와 같이 고전적인 C-style `for` 루프를 사용하게 됩니다.
// [Before] 고전적인 for 루프
var fruits = ['사과', '바나나', '오렌지'];
for (var i = 0; i < fruits.length; i++) {
print('인덱스 $i: ${fruits[i]}');
}
forEachIndexed를 사용하면 이 과정을 훨씬 더 Dart스럽고 선언적인 방식으로 표현할 수 있습니다.
import 'package:collection/collection.dart';
var fruits = ['사과', '바나나', '오렌지'];
// [After] 선언적인 forEachIndexed
fruits.forEachIndexed((index, element) {
print('인덱스 $index: $element');
});
인덱스를 활용하여 새로운 리스트를 생성하고 싶다면 `mapIndexed`가 완벽한 해결책입니다.
import 'package:collection/collection.dart';
var fruits = ['사과', '바나나', '오렌지'];
final indexedFruits = fruits.mapIndexed((index, element) => '${index + 1}번 과일: $element').toList();
print(indexedFruits); // [1번 과일: 사과, 2번 과일: 바나나, 3번 과일: 오렌지]
데이터 묶음 처리: `chunked`
긴 리스트를 일정한 크기의 여러 작은 리스트 묶음(chunk)으로 나누어야 할 때가 있습니다. 예를 들어, 갤러리 앱에서 한 줄에 3개의 이미지를 보여주기 위해 전체 이미지 리스트를 3개씩 묶거나, 대량의 데이터를 API로 전송할 때 100개씩 끊어서 보내는 경우가 그렇습니다. `chunked` 함수는 이 작업을 매우 간단하게 만들어 줍니다.
import 'package:collection/collection.dart';
final numbers = List.generate(10, (i) => i + 1); // [1, 2, ..., 10]
// 리스트를 3개씩 묶기
final chunks = numbers.chunked(3);
print(chunks.toList()); // [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
// 마지막 묶음은 남은 원소들로만 구성됩니다.
이 기능은 Flutter의 `GridView`나 `Row`/`Column`을 동적으로 구성할 때 특히 유용하여, 복잡한 UI 레이아웃 로직을 크게 단순화시킬 수 있습니다.
그 외 유용한 확장 함수들
sum,average,min,max:Iterable<num>에 대한 집계 함수입니다.list.map((e) => e.price).sum과 같이 사용하여 숫자 리스트의 합계, 평균 등을 손쉽게 계산할 수 있습니다.isNullOrEmpty,isNotNullOrEmpty:Iterable?에 대한 확장 함수로,list == null || list.isEmpty와 같은 번거로운 체크를list.isNullOrEmpty한 줄로 줄여줍니다.disjoint: 두 컬렉션이 공통된 원소를 하나도 가지고 있지 않으면 `true`를 반환합니다.equals,hashCode: 앞에서 다룬 `Equality` 클래스의 기능을 확장 함수 형태로 제공합니다.list1.equals(list2, const ListEquality())와 같이 사용할 수 있습니다.
4. 정렬의 기술: 다중 조건과 안정성(Stable Sort)
데이터를 정렬하는 것은 가장 기본적인 작업 중 하나입니다. Dart의 `List.sort()` 메서드는 간단한 정렬에는 충분하지만, 실무에서는 더 복잡한 요구사항과 마주하게 됩니다. 예를 들어, 1차 기준으로 정렬 후, 1차 기준값이 같으면 2차, 3차 기준으로 이어서 정렬해야 하는 경우가 그렇습니다.
문제점: 복잡하고 가독성 낮은 중첩 비교 로직
사용자 목록을 1) 레벨(level)이 높은 순(내림차순)으로, 2) 레벨이 같다면 가입일(joinDate)이 빠른 순(오름차순)으로, 3) 가입일도 같다면 이름(name)의 알파벳 순(오름차순)으로 정렬하는 시나리오를 생각해 봅시다. `sort` 메서드만 사용하면 비교 함수가 아래와 같이 매우 복잡해집니다.
class User {
final String name;
final int level;
final DateTime joinDate;
User(this.name, this.level, this.joinDate);
@override
String toString() => 'User($name, lv:$level, on:${joinDate.toIso8601String().substring(0, 10)})';
}
void main() {
var users = [
User('Chris', 5, DateTime(2023, 5, 10)),
User('Alice', 7, DateTime(2022, 1, 1)),
User('Bob', 5, DateTime(2023, 1, 20)),
User('David', 3, DateTime(2023, 8, 5)),
User('Eve', 5, DateTime(2023, 5, 10)),
];
// [Before] 복잡한 if-else 체인
users.sort((a, b) {
// 1. 레벨을 내림차순으로 비교
final levelCompare = b.level.compareTo(a.level);
if (levelCompare != 0) return levelCompare;
// 2. 레벨이 같으면 가입일을 오름차순으로 비교
final dateCompare = a.joinDate.compareTo(b.joinDate);
if (dateCompare != 0) return dateCompare;
// 3. 가입일도 같으면 이름을 오름차순으로 비교
return a.name.compareTo(b.name);
});
users.forEach(print);
}
이 코드는 정렬 기준이 추가될수록 `if` 문이 계속 늘어나고, 오름차순(a.compareTo(b))과 내림차순(b.compareTo(a))을 혼용하다 보면 실수가 발생하기 쉽습니다. 가독성이 떨어지는 것은 물론입니다.
해결책: `compareBy`, `thenBy`를 이용한 우아한 체이닝
collection 패키지는 이러한 다중 조건 정렬을 위해 마치 SQL의 `ORDER BY` 절처럼, 정렬 기준을 체인 형태로 엮을 수 있는 `compareBy`와 `thenBy` 함수를 제공합니다.
import 'package:collection/collection.dart';
// User 클래스는 위와 동일
void main() {
var users = [ /* ... 동일한 사용자 데이터 ... */ ];
// [After] 명확하고 조합 가능한 비교 함수
final comparator = compareBy((u) => u.level, (a, b) => b.compareTo(a)) // 1. level 내림차순
.thenBy((u) => u.joinDate) // 2. joinDate 오름차순
.thenBy((u) => u.name); // 3. name 오름차순
users.sort(comparator);
users.forEach(print);
/*
출력 결과:
User(Alice, lv:7, on:2022-01-01)
User(Bob, lv:5, on:2023-01-20)
User(Chris, lv:5, on:2023-05-10) // Chris와 Eve는 레벨과 가입일이 같으므로
User(Eve, lv:5, on:2023-05-10) // 이름순(Chris -> Eve)으로 정렬됨
User(David, lv:3, on:2023-08-05)
*/
}
compareBy(selector, [comparer])는 첫 번째 정렬 기준을 설정하고, thenBy(selector)는 앞선 비교 결과가 0(동일함)일 경우에만 실행되는 다음 정렬 기준을 계속해서 연결합니다. 코드가 훨씬 선언적으로 바뀌어 "레벨로 비교하고, 그 다음엔 가입일로, 그 다음엔 이름으로 비교하라"는 의도가 명확하게 드러납니다.
순서의 중요성: 안정 정렬(Stable Sort)과 `mergeSort`
알고 계셨나요? Dart의 기본 `List.sort()`는 불안정 정렬(unstable sort)입니다. 안정 정렬이란, 정렬 키가 동일한 원소들의 기존 상대적 순서가 정렬 후에도 그대로 유지되는 것을 의미합니다. 위 예제에서 'Chris'와 'Eve'는 레벨과 가입일이 모두 같습니다. 만약 원본 리스트에서 'Eve'가 'Chris'보다 앞에 있었다면, 불안정 정렬에서는 정렬 후 순서가 뒤바뀔 수도 있습니다. 하지만 안정 정렬에서는 'Eve'가 여전히 'Chris'보다 앞에 위치하는 것이 보장됩니다.
데이터의 원래 순서 유지가 중요한 시나리오(예: 시간순으로 입력된 데이터를 다른 기준으로 정렬하되, 기준이 같으면 입력 순서를 유지하고 싶을 때)에서는 반드시 안정 정렬을 사용해야 합니다. collection 패키지는 안정 정렬 알고리즘인 병합 정렬(Merge Sort)을 구현한 mergeSort 함수를 제공합니다.
import 'package:collection/collection.dart';
// ... User 클래스 ...
void main() {
var users = [
User('Eve', 5, DateTime(2023, 5, 10)), // Eve가 Chris보다 앞에 있음
User('Chris', 5, DateTime(2023, 5, 10)),
User('Alice', 7, DateTime(2022, 1, 1)),
];
// 레벨(내림차순) 기준으로만 정렬.
// 안정 정렬이므로 레벨이 같은 Eve와 Chris의 원래 순서는 유지되어야 함.
mergeSort(users, compare: (a, b) => b.level.compareTo(a.level));
users.forEach(print);
/*
출력 결과:
User(Alice, lv:7, on:2022-01-01)
User(Eve, lv:5, on:2023-05-10) // Eve가 Chris보다 앞에 있는 순서 유지!
User(Chris, lv:5, on:2023-05-10)
*/
}
| 함수 | 알고리즘 | 안정성 | 특징 | 사용 시나리오 |
|---|---|---|---|---|
List.sort() |
인트로소트 (Introsort) | ❌ 불안정 (Unstable) | 일반적으로 가장 빠름. 제자리(in-place) 정렬. | 정렬 키가 같은 원소들의 상대적 순서가 중요하지 않은 대부분의 경우. |
mergeSort() |
병합 정렬 (Merge Sort) | ✅ 안정 (Stable) | 안정성을 보장. 추가 메모리 공간 필요. | 정렬 후에도 원래의 상대적 순서를 유지해야 하는 중요한 경우. (예: UI 표시 순서) |
5. 특수 목적을 위한 컬렉션: 문제 해결의 비장의 무기
collection 패키지는 일반적인 `List`, `Map`, `Set`을 보강하는 것 외에도, 특정 문제 해결에 최적화된 매우 유용한 특수 컬렉션 클래스들을 제공합니다. 적재적소에 이 클래스들을 활용하면 복잡한 알고리즘을 직접 구현하는 수고를 덜 수 있습니다.
우선순위 큐: `PriorityQueue`
일반적인 큐(Queue)는 선입선출(FIFO) 방식으로 동작합니다. 먼저 들어온 데이터가 먼저 나갑니다. 하지만 `PriorityQueue`는 다릅니다. 데이터를 꺼낼 때(removeFirst()), 들어온 순서가 아니라 '가장 높은 우선순위'를 가진 원소를 먼저 꺼냅니다. 우선순위의 기준은 큐를 생성할 때 전달하는 비교(comparator) 함수에 의해 결정됩니다.
이 자료구조는 작업 스케줄링(긴급한 작업을 먼저 처리), 최단 경로 탐색 알고리즘(다익스트라 알고리즘에서 가장 비용이 적은 노드를 먼저 탐색), 이벤트 시뮬레이션 등 다양한 분야에서 핵심적인 역할을 합니다.
import 'package:collection/collection.dart';
// 숫자가 낮을수록 우선순위가 높은 작업(Task)
class Task {
final String name;
final int priority;
Task(this.name, this.priority);
@override
String toString() => 'Task("$name", priority: $priority)';
}
void main() {
// priority가 낮은(숫자가 작은) Task를 높은 우선순위로 간주하는 큐
final taskQueue = PriorityQueue<Task>((a, b) => a.priority.compareTo(b.priority));
taskQueue.addAll([
Task('UI 렌더링', 2),
Task('사용자 입력 처리', 1), // 가장 긴급
Task('네트워크 요청', 3),
Task('로그 저장', 5), // 가장 나중
]);
print('처리할 작업들: ${taskQueue.toList()}'); // 내부 순서는 보장되지 않음
while (taskQueue.isNotEmpty) {
print('처리 시작: ${taskQueue.removeFirst()}'); // 꺼낼 때는 항상 우선순위가 가장 높은 것부터!
}
}
실행 결과는 다음과 같습니다. 큐에 추가된 순서와 관계없이 우선순위가 높은(숫자가 낮은) 작업부터 순서대로 처리되는 것을 명확히 확인할 수 있습니다.
처리할 작업들: [Task("사용자 입력 처리", priority: 1), Task("UI 렌더링", priority: 2), Task("네트워크 요청", priority: 3), Task("로그 저장", priority: 5)]
처리 시작: Task("사용자 입력 처리", priority: 1)
처리 시작: Task("UI 렌더링", priority: 2)
처리 시작: Task("네트워크 요청", priority: 3)
처리 시작: Task("로그 저장", priority: 5)
키 정규화 맵: `CanonicalizedMap`
CanonicalizedMap은 맵의 키(key)를 저장하거나 조회할 때, 키를 특정 규칙에 따라 '정규화(canonicalize)'하는 함수를 거치도록 만든 특수한 맵입니다. 가장 대표적이고 유용한 사용 사례는 대소문자를 구분하지 않는 맵을 만드는 것입니다. HTTP 헤더를 처리할 때 헤더 이름은 대소문자를 구분하지 않는다는 명세가 있는데, 바로 이런 경우에 완벽하게 들어맞습니다.
import 'package:collection/collection.dart';
void main() {
// 키를 소문자로 정규화하는 맵 생성
final headerMap = CanonicalizedMap<String, String, String>.from(
{'Content-Type': 'application/json', 'X-Request-ID': 'abc-123'},
(key) => key.toLowerCase(), // 정규화 함수: 모든 키를 소문자로 변환
);
// 어떤 대소문자로 접근하든 동일한 값에 접근 가능
print(headerMap['content-type']); // application/json
print(headerMap['Content-type']); // application/json
print(headerMap['CONTENT-TYPE']); // application/json
headerMap['Authorization'] = 'Bearer token';
print(headerMap['authorization']); // Bearer token
}
이 외에도 사용자 아이디를 키로 사용하되 공백을 모두 제거하여 저장하거나, 파일 경로를 키로 사용하면서 항상 동일한 구분자(`/`)를 사용하도록 정규화하는 등 다양한 응용이 가능하여 데이터의 일관성을 유지하는 데 큰 도움을 줍니다.
결론: 이제 `collection` 패키지는 선택이 아닌 필수입니다
지금까지 우리는 collection 패키지가 제공하는 기능들의 극히 일부이지만, 가장 핵심적인 부분들을 깊이 있게 살펴보았습니다. 이 패키지는 단순히 몇 줄의 코드를 줄여주는 편의성 도구를 넘어, Dart와 Flutter로 데이터를 다루는 방식 자체를 근본적으로 개선하는 필수 라이브러리입니다.
`Equality`를 통해 상태 변화를 정확하게 감지하여 불필요한 리빌드를 막고 앱의 성능을 최적화할 수 있습니다. `groupBy`를 사용하면 복잡한 UI 구조에 맞는 데이터 형태로 손쉽게 변환하여 비즈니스 로직과 UI 로직을 명확하게 분리할 수 있습니다. 수많은 확장 함수들은 반복적인 코드를 제거하여 개발 생산성을 극대화하고, 코드의 의도를 명확하게 드러내 줍니다. 고급 정렬 기능과 특수 목적 컬렉션들은 까다로운 요구사항을 표준화되고 검증된 방법으로 해결할 수 있도록 돕습니다.
만약 여러분의 프로젝트에 아직 collection 패키지가 없다면, 지금 바로 pub.dev를 방문하여 pubspec.yaml에 추가하십시오. 그리고 당장 여러분의 코드베이스에서 가장 복잡하게 느껴지는 `for` 루프나 `sort` 비교 함수를 리팩토링해 보세요. 아마 "이 강력한 도구를 왜 이제야 알았을까?"라는 기분 좋은 감탄사를 내뱉게 될 것입니다.
Dart 팀이 직접 제공하는 이 강력하고 신뢰할 수 있는 도구를 적극적으로 활용하여, 더 깨끗하고, 더 효율적이며, 무엇보다 더 즐거운 Flutter/Dart 개발 여정을 이어가시길 바랍니다.
Post a Comment