컬렉션이란
Illuminate\Support\Collection 클래스는 배열 데이터를 조작하기 위한 유창한 래퍼입니다.
PHP의 표준 배열 함수를 개별로 호출하는 대신 메서드 체인으로 직관적으로 데이터를 가공할 수 있습니다.
// 일반 배열 조작
$names = array_filter (
array_map ( fn ( $user ) => $user [ 'name' ], $users ),
fn ( $name ) => $name !== null
);
// 컬렉션을 사용하면
$names = collect ( $users )
-> pluck ( 'name' )
-> filter ()
-> values ();
컬렉션은 불변(immutable) 입니다. 각 메서드는 원본 컬렉션을 변경하지 않고 새로운 컬렉션 인스턴스를 반환합니다. 원본 데이터를 유지하면서 안전하게 조작할 수 있습니다.
컬렉션 작성
collect() 헬퍼
가장 자주 사용되는 방법입니다. 배열을 전달해 컬렉션을 생성합니다.
use Illuminate\Support\ Collection ;
// 배열에서 컬렉션을 작성
$users = collect ([
[ 'name' => '山田太郎' , 'age' => 28 , 'role' => 'admin' ],
[ 'name' => '鈴木花子' , 'age' => 34 , 'role' => 'editor' ],
[ 'name' => '佐藤次郎' , 'age' => 22 , 'role' => 'viewer' ],
]);
// 중첩된 배열도 OK
$products = collect ([
[ 'name' => '노트북 PC' , 'price' => 120000 , 'stock' => 5 ],
[ 'name' => '마우스' , 'price' => 3500 , 'stock' => 20 ],
[ 'name' => '키보드' , 'price' => 8000 , 'stock' => 12 ],
]);
Collection::make()
collect()와 동등합니다. 파사드 스타일로 쓰고 싶은 경우에 사용합니다.
$collection = Collection :: make ([ 1 , 2 , 3 ]);
Collection::fromJson()
JSON 문자열에서 컬렉션을 작성합니다. 외부 API의 응답을 처리하는 경우에 편리합니다.
$collection = Collection :: fromJson ( '[{"name":"山田太郎"},{"name":"鈴木花子"}]' );
자주 사용하는 메서드
map — 각 요소를 변환
컬렉션의 각 요소에 처리를 적용하고 변환된 새로운 컬렉션을 반환합니다.
$users = collect ([
[ 'name' => '山田太郎' , 'email' => '[email protected] ' ],
[ 'name' => '鈴木花子' , 'email' => '[email protected] ' ],
]);
// 이메일 주소를 마스크하여 표시용으로 변환
$masked = $users -> map ( function ( array $user ) {
$parts = explode ( '@' , $user [ 'email' ]);
return [
'name' => $user [ 'name' ],
'email' => substr ( $parts [ 0 ], 0 , 2 ) . '***@' . $parts [ 1 ],
];
});
// [['name' => '山田太郎', 'email' => 'ya***@example.com'], ...]
filter / reject — 조건으로 좁히기
filter()는 조건을 만족하는 요소만 남기고, reject()는 조건을 만족하는 요소를 제외합니다.
$products = collect ([
[ 'name' => '노트북 PC' , 'price' => 120000 , 'in_stock' => true ],
[ 'name' => '마우스' , 'price' => 3500 , 'in_stock' => false ],
[ 'name' => '키보드' , 'price' => 8000 , 'in_stock' => true ],
]);
// 재고 있는 상품만 취득
$inStock = $products -> filter ( fn ( $product ) => $product [ 'in_stock' ]);
// 재고 없는 상품을 제외(reject는 filter의 반전)
$available = $products -> reject ( fn ( $product ) => ! $product [ 'in_stock' ]);
// 인수 없는 filter()는 falsy한 값을 제거
$names = collect ([ '山田' , '' , null , '鈴木' , false ]) -> filter () -> values ();
// ['山田', '鈴木']
filter() 후에는 인덱스가 빠지게 됩니다. values()를 호출하면 0부터 시작하는 연번으로 다시 매겨집니다.
first / last — 요소를 1건 취득
조건을 만족하는 최초 또는 마지막 요소를 반환합니다.
$orders = collect ([
[ 'id' => 1 , 'status' => 'shipped' , 'amount' => 5000 ],
[ 'id' => 2 , 'status' => 'pending' , 'amount' => 12000 ],
[ 'id' => 3 , 'status' => 'pending' , 'amount' => 3500 ],
]);
// 최초의 미처리 주문을 취득
$nextOrder = $orders -> first ( fn ( $order ) => $order [ 'status' ] === 'pending' );
// ['id' => 2, 'status' => 'pending', 'amount' => 12000]
// 조건에 일치하지 않는 경우의 기본값
$order = $orders -> first ( fn ( $order ) => $order [ 'status' ] === 'cancelled' , null );
// 마지막 요소
$latest = $orders -> last ();
pluck — 특정 키의 값만 꺼내기
중첩된 배열이나 모델에서 특정 필드만 뽑아냅니다.
$users = collect ([
[ 'id' => 1 , 'name' => '山田太郎' , 'department' => '개발부' ],
[ 'id' => 2 , 'name' => '鈴木花子' , 'department' => '영업부' ],
[ 'id' => 3 , 'name' => '佐藤次郎' , 'department' => '개발부' ],
]);
// 이름만 꺼내기
$names = $users -> pluck ( 'name' );
// ['山田太郎', '鈴木花子', '佐藤次郎']
// id 를 키로 매핑
$nameById = $users -> pluck ( 'name' , 'id' );
// [1 => '山田太郎', 2 => '鈴木花子', 3 => '佐藤次郎']
groupBy — 특정 키로 그룹화
$users = collect ([
[ 'name' => '山田太郎' , 'department' => '개발부' ],
[ 'name' => '鈴木花子' , 'department' => '영업부' ],
[ 'name' => '佐藤次郎' , 'department' => '개발부' ],
[ 'name' => '田中美咲' , 'department' => '영업부' ],
]);
$byDepartment = $users -> groupBy ( 'department' );
// [
// '개발부' => [['name' => '山田太郎', ...], ['name' => '佐藤次郎', ...]],
// '영업부' => [['name' => '鈴木花子', ...], ['name' => '田中美咲', ...]],
// ]
// 클로저로 그룹 키를 동적으로 지정
$byFirstChar = $users -> groupBy ( fn ( $user ) => mb_substr ( $user [ 'name' ], 0 , 1 ));
sortBy / sortByDesc — 정렬
$products = collect ([
[ 'name' => '노트북 PC' , 'price' => 120000 ],
[ 'name' => '마우스' , 'price' => 3500 ],
[ 'name' => '키보드' , 'price' => 8000 ],
]);
// 가격의 오름차순
$cheapFirst = $products -> sortBy ( 'price' );
// 가격의 내림차순
$expensiveFirst = $products -> sortByDesc ( 'price' );
// 여러 키로 정렬
$sorted = $products -> sortBy ([
[ 'price' , 'asc' ],
[ 'name' , 'asc' ],
]);
each — 각 요소에 처리를 실행
부작용이 있는 처리(로그 출력, 이메일 전송 등)에 사용합니다. 컬렉션 자체는 변경하지 않습니다.
$orders = collect ([
[ 'id' => 1 , 'user_id' => 10 , 'amount' => 5000 ],
[ 'id' => 2 , 'user_id' => 11 , 'amount' => 12000 ],
]);
$orders -> each ( function ( array $order ) {
\ Log :: info ( "주문 #{ $order ['id']} 를 처리 중" , [ 'amount' => $order [ 'amount' ]]);
// 알림 전송이나 큐에의 디스패치 등
});
// false 를 반환하면 도중에 처리를 빠져나올 수 있음
$orders -> each ( function ( array $order ) {
if ( $order [ 'amount' ] > 10000 ) {
return false ; // 루프를 빠져나옴
}
// ...
});
flatMap — 맵 후에 1단계 평탄화
각 요소를 배열로 변환하고 전체를 1차원으로 평탄화합니다.
$users = collect ([
[ 'name' => '山田太郎' , 'tags' => [ 'php' , 'laravel' ]],
[ 'name' => '鈴木花子' , 'tags' => [ 'javascript' , 'vue' ]],
]);
// 모든 사용자의 태그를 플랫한 목록으로
$allTags = $users -> flatMap ( fn ( $user ) => $user [ 'tags' ]);
// ['php', 'laravel', 'javascript', 'vue']
reduce — 집계
컬렉션 전체를 하나의 값으로 접어 넣습니다.
$orders = collect ([
[ 'product' => '노트북 PC' , 'quantity' => 1 , 'price' => 120000 ],
[ 'product' => '마우스' , 'quantity' => 2 , 'price' => 3500 ],
[ 'product' => '키보드' , 'quantity' => 1 , 'price' => 8000 ],
]);
// 합계 금액을 계산
$total = $orders -> reduce (
fn ( $carry , $order ) => $carry + ( $order [ 'price' ] * $order [ 'quantity' ]),
0
);
// 135000
단순한 합계라면 sum()을 사용할 수 있습니다: $orders->sum(fn ($o) => $o['price'] * $o['quantity'])
reduceInto — 객체로 집계
reduce와 동일하게 접어 넣기 처리를 수행하지만, 콜백이 반환값을 반환할 필요가 없다는 점이 다릅니다. 기존의 객체를 직접 뮤테이트할 때 편리합니다.
class OrderStats
{
public int $total = 0 ;
public int $count = 0 ;
}
$orders = collect ([
[ 'amount' => 100 ],
[ 'amount' => 250 ],
[ 'amount' => 50 ],
]);
$stats = $orders -> reduceInto ( new OrderStats , function ( OrderStats $stats , array $order ) {
$stats -> total += $order [ 'amount' ];
$stats -> count ++ ;
});
$stats -> total ; // 400
$stats -> count ; // 3
reduce는 콜백의 반환값이 다음의 $carry가 되므로, 원시 값의 집계에 적합합니다. reduceInto는 객체를 그대로 계속 변경하기 때문에 단순한 코드가 됩니다.
스칼라나 배열로 집계하는 경우, 콜백에서 참조 전달(&)을 사용합니다.
$collection = collect ([ 1 , 2 , 3 , 4 , 5 ]);
$even = $collection -> reduceInto ([], function ( array & $result , int $value ) {
if ( $value % 2 === 0 ) {
$result [] = $value ;
}
});
// [2, 4]
chunk — 분할
큰 컬렉션을 지정 사이즈로 분할합니다. 배치 처리나 화면 표시에서 편리합니다.
$users = collect ( range ( 1 , 100 )) -> map ( fn ( $i ) => [ 'id' => $i , 'name' => "사용자{ $i }" ]);
// 10건씩으로 분할
$chunks = $users -> chunk ( 10 );
// $chunks->count() === 10
// 배치별로 처리
$chunks -> each ( function ( \Illuminate\Support\ Collection $batch ) {
// 배치별의 DB 조작이나 이메일 전송 등
});
메서드 체인
컬렉션의 진가는 메서드 체인에 있습니다. 여러 조작을 이어서 복잡한 데이터 변환을 하나의 식으로 표현할 수 있습니다.
$orders = collect ([
[ 'customer' => '山田太郎' , 'status' => 'completed' , 'amount' => 5000 , 'category' => '전자기기' ],
[ 'customer' => '鈴木花子' , 'status' => 'pending' , 'amount' => 12000 , 'category' => '전자기기' ],
[ 'customer' => '佐藤次郎' , 'status' => 'completed' , 'amount' => 3500 , 'category' => '문구' ],
[ 'customer' => '田中美咲' , 'status' => 'completed' , 'amount' => 8000 , 'category' => '전자기기' ],
[ 'customer' => '伊藤健一' , 'status' => 'cancelled' , 'amount' => 2000 , 'category' => '문구' ],
]);
// 완료된 전자기기 주문을 금액 내림차순으로 취득하고, 고객명과 금액만 추출
$result = $orders
-> filter ( fn ( $order ) => $order [ 'status' ] === 'completed' )
-> filter ( fn ( $order ) => $order [ 'category' ] === '전자기기' )
-> sortByDesc ( 'amount' )
-> map ( fn ( $order ) => [
'customer' => $order [ 'customer' ],
'amount' => number_format ( $order [ 'amount' ]) . '원' ,
])
-> values ();
// [
// ['customer' => '田中美咲', 'amount' => '8,000원'],
// ['customer' => '山田太郎', 'amount' => '5,000원'],
// ]
Eloquent 와의 연계
Eloquent 쿼리의 결과는 항상 Illuminate\Database\Eloquent\Collection 인스턴스로 반환됩니다.
이것은 기본 Collection을 상속하고 있어 위의 메서드가 모두 사용 가능합니다.
use App\Models\ User ;
use App\Models\ Order ;
// get() 의 결과는 컬렉션
$users = User :: where ( 'is_active' , true ) -> get (); // Collection
// 컬렉션의 메서드를 그대로 사용 가능
$adminEmails = User :: all ()
-> filter ( fn ( $user ) => $user -> role === 'admin' )
-> pluck ( 'email' );
// 릴레이션의 로드된 데이터도 컬렉션 조작 가능
$orders = Order :: with ( 'items' ) -> where ( 'status' , 'completed' ) -> get ();
$summary = $orders -> map ( fn ( $order ) => [
'id' => $order -> id ,
'customer' => $order -> user -> name ,
'item_count' => $order -> items -> count (),
'total' => $order -> items -> sum ( 'price' ),
]);
데이터베이스에서 처리할 수 있는 필터링이나 정렬은 컬렉션 조작이 아닌 쿼리 빌더(where, orderBy 등)에서 수행합시다. 컬렉션으로 변환한 후에 필터링하면 불필요한 데이터를 모두 메모리에 읽어 들이고 맙니다.
Eloquent 고유의 컬렉션 메서드
Eloquent\Collection에는 추가의 메서드가 있습니다.
$users = User :: all ();
// 모델을 ID로 검색
$user = $users -> find ( 1 );
// 주 키의 컬렉션을 취득
$ids = $users -> modelKeys (); // [1, 2, 3, ...]
// 릴레이션을 함께 로드
$users -> load ( 'orders' , 'profile' );
// 차집합·교집합
$diff = $users -> diff ( $otherUsers );
$intersect = $users -> intersect ( $otherUsers );
Lazy Collections
일반적인 컬렉션은 데이터 전체를 메모리에 읽어 들이지만, LazyCollection은 PHP의 제너레이터를 활용하여 데이터를 한 건씩 처리합니다.
수만 건 이상의 대량 데이터를 다루는 경우에 메모리를 절약할 수 있습니다.
use Illuminate\Support\ LazyCollection ;
// 일반 컬렉션: 전체 데이터를 메모리에 읽어 들임
$users = User :: all (); // 10만 건이 있으면 10만 건이 메모리에 올라감
// LazyCollection: 한 건씩 처리
User :: cursor () -> each ( function ( User $user ) {
// 사용자를 한 건씩 처리
// 메모리에 유지하는 것은 항상 한 건분만
});
LazyCollection 작성
use Illuminate\Support\ LazyCollection ;
// 클로저(제너레이터)에서 작성
$lazy = LazyCollection :: make ( function () {
$handle = fopen ( 'large-file.csv' , 'r' );
while (( $line = fgetcsv ( $handle )) !== false ) {
yield $line ;
}
fclose ( $handle );
});
// Eloquent의 cursor() 메서드가 LazyCollection 을 반환
$lazy = User :: where ( 'is_active' , true ) -> cursor ();
대량 데이터의 배치 처리
use App\Models\ Order ;
// 전체 주문을 한 건씩 처리(메모리 효율이 좋음)
Order :: cursor ()
-> filter ( fn ( $order ) => $order -> amount > 10000 )
-> each ( fn ( $order ) => $order -> sendConfirmationEmail ());
// cursor() 로 전체를 한 건씩 페치하면서 filter/each 로 파이프라인 처리
Order :: where ( 'status' , 'completed' )
-> cursor ()
-> filter ( fn ( $order ) => $order -> amount > 10000 )
-> each ( fn ( $order ) => $order -> sendConfirmationEmail ());
cursor()는 데이터베이스에서 한 건씩 페치하기 때문에 대량의 레코드를 처리할 때 메모리를 대폭 절약할 수 있습니다. 다만 데이터베이스 접속은 처리의 완료까지 유지됩니다.
take 와 skip 으로 페이징
$lazy = LazyCollection :: make ( function () {
foreach ( range ( 1 , 1000000 ) as $i ) {
yield $i ;
}
});
// 최초의 1000건을 스킵하고 다음의 100건을 취득
$page = $lazy -> skip ( 1000 ) -> take ( 100 ) -> values ();
메서드 설명 collect($array)컬렉션을 작성 map($callback)각 요소를 변환 filter($callback)조건으로 좁히기 reject($callback)조건에 맞는 요소를 제외 first($callback)조건을 만족하는 최초의 요소를 취득 pluck($key)특정 키의 값을 뽑아냄 groupBy($key)특정 키로 그룹화 sortBy($key)오름차순으로 정렬 sortByDesc($key)내림차순으로 정렬 each($callback)각 요소에 처리 실행(부작용) flatMap($callback)맵 후에 평탄화 reduce($callback, $initial)집계해서 하나의 값으로 chunk($size)지정 사이즈로 분할 values()인덱스를 다시 매김 sum($key)합계값을 계산 count()건수를 취득
컬렉션은 배열 함수보다 훨씬 가독성이 높아집니다. // 배열 함수를 사용한 경우
$result = array_values ( array_filter (
array_map ( fn ( $u ) => $u [ 'name' ], $users ),
fn ( $name ) => strlen ( $name ) > 2
));
// 컬렉션을 사용한 경우
$result = collect ( $users )
-> pluck ( 'name' )
-> filter ( fn ( $name ) => strlen ( $name ) > 2 )
-> values ()
-> all ();
일반 컬렉션 : 수백~수천 건 정도의 데이터. 단순하고 직관적.
LazyCollection : 수만 건 이상의 대량 데이터. 메모리를 절약하고 싶은 경우. cursor()와 조합하면 효과적.
Eloquent의 get()은 일반 컬렉션, cursor()는 LazyCollection을 반환합니다.