PHP · 기본
웹으로 시작하는 PHP
배열 - 목록과 연관 배열
인덱스·연관 배열, 배열 함수(array_map·filter·usort), 구조 분해, 참조 foreach 함정
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 조건문과 반복문으로 프로그램의 흐름을 만들었다. 이 장에서는 흐름이 다루는 데이터를 묶는 방법인 배열(array)을 배운다. 배열은 값 여러 개를 하나의 변수에 담는 자료형이다. 빵집 사이트에는 메뉴, 주문 품목, 대기 손님처럼 묶어서 다룰 데이터가 계속 나오므로, 배열을 다루는 손이 익어야 이후 장의 예제를 따라갈 수 있다.
- 번호로 찾는 인덱스 배열과 이름으로 찾는 연관 배열을 만들고 읽고 고친다.
- array_map, array_filter, usort 로 배열을 변환, 걸러내기, 정렬한다.
- 구조 분해(destructuring)로 배열의 값을 여러 변수에 한 번에 꺼낸다.
- 참조를 쓰는 foreach 뒤에 남는 함정을 알고 피한다.
문제 상황
빵집 주인이 오늘의 메뉴를 알려 주었다. 식빵, 소보로빵, 크루아상, 단팥빵이 있고, 각각 가격과 재고가 다르다. 소보로빵은 오전에 다 팔렸다. 화면에 보여 줄 목록은 재고가 남은 빵만, 가격이 싼 순서대로 나와야 한다. 손님이 담은 장바구니에서는 빵 이름으로 가격을 찾아 합계도 계산해야 한다.
변수를 빵마다 $bread1Name, $bread1Price 식으로 만들면 빵이 하나 늘 때마다 코드를 고쳐야 하고, 반복문으로 묶을 수도 없다. 데이터를 배열에 담으면 개수가 바뀌어도 같은 코드가 동작한다.
인덱스 배열과 연관 배열
PHP 의 배열은 하나의 종류다
다른 언어에서는 번호로 찾는 목록과 이름으로 찾는 사전을 서로 다른 자료형으로 나눈다. PHP 는 둘을 배열 하나로 처리한다. 배열은 키(key)와 값(value)의 쌍을 순서대로 담은 것이고, 키가 0, 1, 2 처럼 자동으로 붙은 번호이면 인덱스 배열, 직접 정한 문자열이면 연관 배열이라 부른다. 부르는 이름만 다르고 내부는 같다.
키로 쓸 수 있는 것은 정수와 문자열이다. 다만 '7' 처럼 10진 정수로 읽히는 문자열은 정수 7 로 바뀌어 저장된다. 이 사실은 키를 문자열 그대로 유지하려는 사람이 가끔 놀라는 지점이다.
만들고, 읽고, 덧붙이기
대괄호 [ ] 로 배열을 만든다. 값만 나열하면 키가 0 부터 자동으로 붙고, 키 => 값 꼴로 쓰면 키를 직접 정한다. 읽을 때는 $배열[키] 를 쓴다. 끝에 값을 덧붙일 때는 $배열[] = 값 이라고 쓴다. 배열 안에 배열을 넣을 수도 있어서, 메뉴처럼 "빵 한 개의 정보"를 연관 배열로 만들고 그런 것들을 인덱스 배열에 모으는 구조를 흔히 쓴다.
존재하지 않는 키를 읽으면 PHP 는 경고(Warning)를 내고 null 을 돌려준다. 키가 있는지 확실하지 않을 때는 isset($배열[키]) 로 확인하거나, 없을 때 쓸 기본값을 ?? 로 지정한다. $배열[키] ?? 기본값 은 키가 없거나 값이 null 이면 기본값을 쓴다.
자주 쓰는 기본 함수는 다음과 같다.
| 함수 | 하는 일 | 원본 배열 |
|---|---|---|
| count($a) | 원소 개수를 돌려준다 | 바뀌지 않는다 |
| array_shift($a) | 맨 앞 값을 꺼내고 번호를 다시 매긴다 | 바뀐다 |
| array_keys($a) | 키만 모은 배열을 돌려준다 | 바뀌지 않는다 |
| array_column($rows, 열, 키열) | 행 배열에서 한 열을 뽑아 배열로 만든다 | 바뀌지 않는다 |
배열 함수로 변환하고 걸러내고 정렬하기
배열 전체를 훑으며 무언가 하는 일은 foreach 로도 쓸 수 있지만, 목적이 뚜렷한 작업에는 전용 함수가 읽기 쉽다. 아래 함수들은 "각 원소에 적용할 규칙"을 인수로 받는다. 그 규칙은 fn(인수) => 결과 꼴의 이름 없는 짧은 함수로 쓴다. 함수를 정의하는 방법은 함수를 다루는 다음 장에서 자세히 배우므로, 지금은 "인수를 받아 결과 하나를 돌려주는 한 줄짜리 규칙"으로 읽으면 충분하다.
array_map: 모든 원소를 바꾼다
array_map(규칙, 배열) 은 배열의 모든 원소에 규칙을 적용해 새 배열을 만든다. 원소 개수와 키는 그대로다. 메뉴에서 이름만 뽑거나 가격에 세금을 붙일 때 쓴다.
array_filter: 조건에 맞는 것만 남긴다
array_filter(배열, 규칙) 은 규칙이 true 를 돌려주는 원소만 남긴다. 여기서 놓치기 쉬운 점이 있다. 남은 원소는 원래의 키를 그대로 가진다. 0, 2, 3 번이 남으면 결과 배열의 키도 0, 2, 3 이다. 번호를 0 부터 다시 매기려면 array_values() 를 거친다.
usort: 규칙으로 정렬한다
usort(배열, 규칙) 은 두 원소 $a, $b 를 비교하는 규칙으로 배열을 정렬한다. 규칙은 $a 가 앞에 와야 하면 음수, 같으면 0, 뒤에 와야 하면 양수를 돌려준다. 이 세 가지 결과를 한 번에 만들어 주는 연산자가 우주선 연산자 <=> 다. $a['price'] <=> $b['price'] 는 오름차순, 두 쪽을 바꿔 쓰면 내림차순이다. 또 하나 조심할 점이 있다. usort 는 함수가 돌려주는 값이 아니라 넘겨 준 변수 자체를 바꾸고, 키를 0 부터 다시 매긴다.
| 함수 | 결과 | 키 | 원본 |
|---|---|---|---|
| array_map | 같은 개수의 새 배열 | 유지한다 | 그대로 |
| array_filter | 줄어든 새 배열 | 유지한다 | 그대로 |
| usort | true 를 돌려준다(쓸 일이 없다) | 0 부터 다시 매긴다 | 바뀐다 |
| array_values | 번호를 다시 매긴 새 배열 | 0 부터 다시 매긴다 | 그대로 |
PHP 8.4 부터는 조건에 맞는 첫 원소를 찾는 array_find, 하나라도 맞는지 보는 array_any, 모두 맞는지 보는 array_all 이 기본으로 들어 있다. 자세한 규칙은 PHP 매뉴얼의 array_find 항목에서 확인할 수 있다.
구조 분해와 참조 foreach
구조 분해
배열에서 값을 꺼내 변수에 담을 때마다 $menu[0]['name'] 처럼 쓰면 길고 읽기 어렵다. 구조 분해는 대입문의 왼쪽에 배열 모양을 쓰고 각 자리에 변수를 놓아 한 번에 꺼내는 문법이다. 인덱스 배열은 [$x, $y] = $배열;, 연관 배열은 ['name' => $n] = $배열; 로 쓴다. 필요 없는 자리는 쉼표만 남기고 비운다. foreach 의 as 뒤에도 같은 모양을 쓸 수 있다. 변수 두 개의 값을 맞바꿀 때 임시 변수 없이 [$a, $b] = [$b, $a]; 라고 쓰는 것도 같은 문법이다.
참조 foreach 의 함정
foreach 는 기본적으로 각 값을 복사해서 변수에 넣는다. 그래서 반복 안에서 변수를 고쳐도 원래 배열은 그대로다. 배열 원소를 직접 고치고 싶으면 foreach ($배열 as &$값) 처럼 변수 앞에 & 를 붙여 참조(reference)로 받는다. 참조는 "같은 칸을 가리키는 또 하나의 이름"이라고 이해하면 된다.
함정은 반복이 끝난 뒤에 있다. 참조로 받은 변수는 반복이 끝나도 마지막 원소를 계속 가리킨다. 이 상태에서 같은 변수 이름으로 foreach 를 또 돌리면, 값을 넣는 동작이 곧 마지막 원소를 덮어쓰는 동작이 된다.
해결은 간단하다. 참조 반복이 끝나면 곧바로 unset($값) 으로 참조를 끊는다. 그러면 변수와 마지막 칸의 연결이 사라진다.
완성 코드
아래 파일을 main.php 로 저장한다. 빵집의 메뉴 배열을 만들고, 앞에서 설명한 내용을 순서대로 확인하는 프로그램이다.
main.php
<?php
declare(strict_types=1);
$menu = [
['name' => '식빵', 'price' => 4500, 'stock' => 12],
['name' => '소보로빵', 'price' => 2200, 'stock' => 0],
['name' => '크루아상', 'price' => 3200, 'stock' => 8],
['name' => '단팥빵', 'price' => 2000, 'stock' => 15],
];
// 1. 읽기
echo "메뉴 개수: ", count($menu), "\n";
echo "첫 번째 빵: ", $menu[0]['name'], "\n";
echo "마지막 빵 가격: ", $menu[array_key_last($menu)]['price'], "\n";
// 2. 인덱스 배열 다루기
$waiting = ['김민수', '이서연'];
$waiting[] = '박지호';
echo count($waiting), "명 대기, 다음 손님: ", array_shift($waiting), "\n";
echo "남은 대기: ", implode(', ', $waiting), "\n";
// 3. array_map, array_filter, usort
$names = array_map(fn(array $item): string => $item['name'], $menu);
echo "이름 목록: ", implode(', ', $names), "\n";
$available = array_filter($menu, fn(array $item): bool => $item['stock'] > 0);
echo "판매 가능 키: ", implode(', ', array_keys($available)), "\n";
$available = array_values($available);
echo "다시 번호 매긴 뒤 개수: ", count($available), "\n";
usort($available, fn(array $a, array $b): int => $a['price'] <=> $b['price']);
foreach ($available as $index => $item) {
echo $index + 1, '. ', $item['name'], ' ', number_format($item['price']), "원\n";
}
// 4. 구조 분해
['name' => $name, 'price' => $price] = $available[0];
echo "가장 싼 빵: {$name} ({$price}원)\n";
[$first, , $third] = $available;
echo $first['name'], ' / ', $third['name'], "\n";
$order = [['단팥빵', 3], ['식빵', 1]];
foreach ($order as [$bread, $qty]) {
echo "- {$bread} x {$qty}\n";
}
// 5. 이름으로 가격 찾기
$priceOf = array_column($menu, 'price', 'name');
echo "바게트 가격: ", $priceOf['바게트'] ?? '없음', "\n";
$cart = ['식빵' => 1, '단팥빵' => 3];
$total = 0;
foreach ($cart as $breadName => $count) {
$total += $priceOf[$breadName] * $count;
}
echo "합계: ", number_format($total), "원\n";
// 6. PHP 8.4 배열 함수
$found = array_find($menu, fn(array $item): bool => $item['name'] === '크루아상');
echo "크루아상 가격: ", $found['price'], "\n";
$hasSoldOut = array_any($menu, fn(array $item): bool => $item['stock'] === 0);
echo $hasSoldOut ? "품절 있음" : "품절 없음", "\n";
// 7. 참조 foreach
$prices = [2000, 3200, 4500];
foreach ($prices as &$p) {
$p += 300;
}
unset($p);
echo "인상 가격: ", implode(', ', $prices), "\n";
$trap = [1, 2, 3];
foreach ($trap as &$t) {
}
foreach ($trap as $t) {
}
echo "함정: ", implode(', ', $trap), "\n";
줄별 해설
메뉴 배열과 읽기(1번 구역)
$menu 는 바깥이 인덱스 배열이고 안쪽 원소는 연관 배열이다. $menu[0]['name'] 은 "0 번 원소의 name 키"를 뜻하며, 왼쪽부터 차례로 한 겹씩 들어간다. array_key_last 는 마지막 키를 돌려주므로, 원소 개수를 직접 세지 않고도 끝 원소를 읽을 수 있다. echo 에 쉼표로 여러 값을 나열하면 왼쪽부터 차례로 출력한다.
대기열(2번 구역)
$waiting[] = '박지호' 는 끝에 덧붙인다. 다음 줄의 echo 는 왼쪽부터 평가하므로, count 가 먼저 3 을 계산하고 array_shift 가 맨 앞의 김민수를 꺼낸다. 그 결과 배열에는 두 명이 남는다. implode 는 배열의 값을 구분자로 이어 붙여 문자열로 만든다.
map, filter, usort(3번 구역)
array_map 의 규칙은 메뉴 한 개를 받아 이름 문자열을 돌려준다. : string 과 : bool 은 돌려주는 값의 타입을 밝힌 것이다. array_filter 는 재고가 0 보다 큰 원소만 남기는데, 소보로빵(1번)이 빠지므로 키가 0, 2, 3 으로 남는다. 그래서 array_values 로 번호를 다시 매겼다. usort 는 가격 오름차순으로 정렬하며, 정렬한 결과가 $available 에 그대로 들어 있다. foreach 의 $index + 1 은 화면용 번호이고, number_format 은 천 단위 쉼표를 붙인다.
구조 분해(4번 구역)
첫 대입문은 name 키의 값을 $name 에, price 키의 값을 $price 에 넣는다. [$first, , $third] 는 가운데 자리를 비워 0 번과 2 번만 꺼낸다. 정렬 뒤 순서가 단팥빵, 크루아상, 식빵이므로 결과는 단팥빵과 식빵이다. foreach ($order as [$bread, $qty]) 는 반복마다 ['단팥빵', 3] 같은 작은 배열을 두 변수로 나눈다. 문자열 안에서 변수 뒤에 한글이 바로 이어져도 읽히도록 {$변수} 형태로 감쌌다.
이름으로 찾기(5번 구역)
array_column($menu, 'price', 'name') 는 "이름 => 가격" 배열을 만든다. 이 배열이 있으면 빵 이름으로 가격을 바로 찾을 수 있다. 없는 이름인 바게트는 ?? 덕분에 경고 없이 "없음"이 출력된다. 장바구니 합계는 연관 배열을 키 => 값 꼴로 돌면서 (가격 × 수량)을 더한다. 식빵 1개 4,500원과 단팥빵 3개 6,000원을 더해 10,500원이다.
PHP 8.4 함수(6번 구역)
array_find 는 규칙이 처음 true 가 되는 원소 자체를 돌려주고, 없으면 null 을 돌려준다. 여기서는 크루아상이 있다는 걸 알기 때문에 바로 ['price'] 를 읽었다. 실제 코드에서는 null 일 수 있으니 확인하는 습관이 필요하다. array_any 는 규칙을 만족하는 원소가 하나라도 있으면 true 다. 재고가 0 인 소보로빵이 있어 "품절 있음"이 나온다. === 는 값과 타입이 모두 같은지 보는 비교로, 느슨한 비교를 조심하라고 했던 앞선 내용과 이어진다.
참조 foreach(7번 구역)
첫 번째 반복은 &$p 로 받아 각 가격에 300 을 더하고, 곧바로 unset($p) 로 참조를 끊었다. 그래서 결과가 정상이다. 두 번째 묶음은 함정을 일부러 재현한다. 첫 반복이 끝나면 $t 는 마지막 칸을 가리킨다. 두 번째 반복은 $t 에 1, 2, 2 를 차례로 넣는데, 넣는 곳이 곧 마지막 칸이라서 마지막 칸이 1 → 2 → (방금 바뀐 값인) 2 로 바뀐다. 세 번째 반복에서 읽는 값은 이미 덮어쓴 2 이므로 결과는 1, 2, 2 다.
실행 결과
$ php main.php
메뉴 개수: 4
첫 번째 빵: 식빵
마지막 빵 가격: 2000
3명 대기, 다음 손님: 김민수
남은 대기: 이서연, 박지호
이름 목록: 식빵, 소보로빵, 크루아상, 단팥빵
판매 가능 키: 0, 2, 3
다시 번호 매긴 뒤 개수: 3
1. 단팥빵 2,000원
2. 크루아상 3,200원
3. 식빵 4,500원
가장 싼 빵: 단팥빵 (2000원)
단팥빵 / 식빵
- 단팥빵 x 3
- 식빵 x 1
바게트 가격: 없음
합계: 10,500원
크루아상 가격: 3200
품절 있음
인상 가격: 2300, 3500, 4800
함정: 1, 2, 2
실무에서 자주 틀리는 것
없는 키를 그냥 읽는다
틀린 코드는 다음과 같다.
$priceOf = ['식빵' => 4500];
echo $priceOf['바게트']; // Warning: Undefined array key "바게트"
화면에 경고가 섞여 나오고 값은 비어 있다. 사용자 입력에서 온 키라면 더 자주 만난다. 기본값을 정해 두면 된다.
echo $priceOf['바게트'] ?? '없음';
array_filter 뒤에 0 번 원소가 있다고 가정한다
$soldOut = array_filter($menu, fn(array $item): bool => $item['stock'] === 0);
echo $soldOut[0]['name']; // 남은 키는 1 이므로 경고가 난다
필터는 키를 유지하므로 남은 원소의 키는 1 이다. 번호로 접근할 계획이라면 먼저 다시 매긴다.
$soldOut = array_values(array_filter($menu, fn(array $item): bool => $item['stock'] === 0));
echo $soldOut[0]['name'];
참조 foreach 뒤에 unset 을 빼먹는다
foreach ($prices as &$p) {
$p += 300;
}
foreach ($prices as $p) { // 마지막 칸이 덮어쓰인다
echo $p, "\n";
}
참조를 끊거나, 아예 참조 없이 새 배열을 만드는 방법이 더 안전하다.
foreach ($prices as &$p) {
$p += 300;
}
unset($p);
// 또는 참조 없이
$prices = array_map(fn(int $p): int => $p + 300, $prices);
usort 규칙에서 true, false 를 돌려준다
usort($menu, fn(array $a, array $b) => $a['price'] > $b['price']);
비교 결과를 bool 로 돌려주는 방식은 PHP 8 부터 권장하지 않으며(deprecated) 알림이 나온다. 같은 경우를 구분하지 못한다는 점도 문제다. 세 갈래를 모두 알려 주는 <=> 를 쓴다.
usort($menu, fn(array $a, array $b): int => $a['price'] <=> $b['price']);
한눈에 보기
| 하고 싶은 일 | 쓰는 것 | 기억할 점 |
|---|---|---|
| 끝에 덧붙이기 | $a[] = 값 | 키는 자동으로 붙는다 |
| 키가 없을 때 기본값 | $a[키] ?? 기본값 | 읽기 경고가 나지 않는다 |
| 모든 원소 바꾸기 | array_map | 개수와 키가 그대로다 |
| 조건에 맞는 것만 남기기 | array_filter | 키가 유지되므로 array_values 를 함께 쓴다 |
| 정렬 | usort 와 <=> | 원본이 바뀌고 키를 다시 매긴다 |
| 첫 일치 원소, 하나라도 일치 | array_find, array_any | PHP 8.4 이상에서 쓴다 |
| 값을 여러 변수로 꺼내기 | [$x, $y] = $a | 키가 있으면 'k' => $x 로 쓴다 |
| 반복하며 원소 고치기 | foreach ($a as &$v) | 끝나면 unset($v) 를 한다 |
연습 문제
$breads = ['식빵' => 4500, '단팥빵' => 2000, '크루아상' => 3200];에서 가격이 3500원 이하인 빵의 이름만 "단팥빵, 크루아상" 형태로 출력하시오. array_filter, array_keys, implode 를 쓴다.- 완성 코드의
$menu를 재고가 많은 순으로 정렬하고, 맨 앞 빵의 이름을 출력하시오. $prices = [2000, 3200, 4500];의 모든 값에서 500 을 뺀 뒤1500, 2700, 4000을 출력하시오. 참조 foreach 를 쓰고, 끝에unset을 넣는 이유도 한 문장으로 쓰시오.$a = '식빵'; $b = '단팥빵';의 값을 구조 분해로 맞바꾸고$a를 출력하시오. 그리고$menu[2]에서name과stock을 구조 분해로 꺼내 "크루아상 8" 을 출력하시오.
정답과 해설
-
<?php declare(strict_types=1); $breads = ['식빵' => 4500, '단팥빵' => 2000, '크루아상' => 3200]; $cheap = array_filter($breads, fn(int $price): bool => $price <= 3500); echo implode(', ', array_keys($cheap)), "\n";array_filter 는 키(빵 이름)를 유지하므로, array_keys 로 이름만 꺼내면 된다. 출력은
단팥빵, 크루아상이다. 원래 순서가 식빵, 단팥빵, 크루아상이므로 그 순서에서 식빵만 빠진다. -
usort($menu, fn(array $a, array $b): int => $b['stock'] <=> $a['stock']); echo $menu[0]['name'], "\n";내림차순은
$a와$b의 자리를 바꿔 쓴다. 재고는 15, 12, 8, 0 이므로 첫 빵은단팥빵이다. -
foreach ($prices as &$p) { $p -= 500; } unset($p); echo implode(', ', $prices), "\n";출력은
1500, 2700, 4000이다. unset 을 넣는 이유는, 남아 있는 참조 변수$p가 마지막 원소를 계속 가리켜서 이후 같은 이름으로 값을 대입하면 마지막 원소가 바뀌기 때문이다. -
$a = '식빵'; $b = '단팥빵'; [$a, $b] = [$b, $a]; echo $a, "\n"; ['name' => $name, 'stock' => $stock] = $menu[2]; echo "{$name} {$stock}\n";오른쪽의
[$b, $a]가 먼저 값 두 개로 만들어진 뒤 왼쪽에 나뉘어 들어가므로, 임시 변수 없이 맞바꿀 수 있다. 첫 출력은단팥빵이다. 두 번째 출력은 완성 코드의$menu를 정렬하기 전 상태를 기준으로 하면크루아상 8이다. 문제 2번에서 정렬한 뒤에 실행하면 2번 원소가 달라지므로, 정렬하지 않은$menu로 확인해야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.