PHP · 심화
설계와 보안으로 깊어지는 PHP
클로저와 함수형 도구
클로저와 use, 화살표 함수, first-class callable 문법(f(...)), array_map·array_filter·array_reduce 파이프라인, 고차 함수로 검증 규칙 조합
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 제너레이터로 큰 파일을 한 줄씩 흘려 보내는 방법을 다뤘다. 이번 장은 값 대신 동작을 다룬다. PHP 에서는 함수도 값이어서 변수에 담고, 인자로 넘기고, 다른 함수가 반환할 수 있다. 이 성질을 쓰면 반복문과 조건문이 길게 늘어선 코드가 작은 조각의 조합으로 바뀐다. 수강 신청 검증을 예제로 삼아, 규칙을 하나씩 만들고 조합하는 과정을 따라간다.
- 클로저(closure)가 바깥 변수를
use로 가져오는 방식과 값 복사·참조의 차이를 설명할 수 있다. - 화살표 함수가 허용하는 범위와 한계를 알고 일반 클로저와 구분해 쓸 수 있다.
f(...)형태의 first-class callable 문법으로 함수·메서드·정적 메서드를 클로저로 만들 수 있다.array_map,array_filter,array_reduce를 이어 붙여 데이터 처리 흐름을 만들 수 있다.- 고차 함수로 검증 규칙을 만들고 합치는 구조를 직접 설계할 수 있다.
문제 상황
수강 신청을 받는 함수가 처음에는 간단했다. 이름이 비어 있는지, 나이가 기준 이상인지 두 가지만 확인하면 됐다. 몇 주 뒤 요구가 늘었다. 코딩 강좌는 8세 이상만 받는다. 잔여 좌석이 없으면 거절한다. 수강료가 0원이면 입력 오류로 본다. 요구가 하나 생길 때마다 if 가 한 겹씩 늘고, 어느 순간 함수 하나가 여섯 가지 책임을 진다.
더 곤란한 것은 사용자에게 보여 줄 메시지다. if 에서 처음 걸린 오류에서 바로 반환하면 사용자는 고치고 다시 제출하기를 여러 번 반복한다. 모든 오류를 한 번에 모아 보여 주려면 변수 하나에 메시지를 쌓는 코드가 또 필요하다. 이 규칙들이 모두 같은 모양, 즉 신청서를 받아서 오류 메시지 또는 없음을 돌려주는 작은 함수라는 점을 알아채면 구조가 달라진다. 규칙을 함수 값으로 만들어 배열에 담고, 배열을 돌며 결과를 모으면 규칙을 추가해도 기존 코드를 건드리지 않는다.
다음 절부터 이 구조에 필요한 도구를 순서대로 살펴본다.
클로저와 화살표 함수
클로저와 use
클로저는 이름 없는 함수이면서, 만들어진 시점의 바깥 변수를 함께 들고 다니는 값이다. PHP 는 함수 안에서 바깥 변수가 자동으로 보이지 않기 때문에, 가져올 변수를 use 로 명시한다.
$rate = 10;
$withUse = function (int $fee) use ($rate): int {
return intdiv($fee * (100 - $rate), 100);
};
use ($rate) 는 클로저가 만들어지는 순간의 값을 복사한다. 이후 바깥에서 $rate 를 바꿔도 클로저 안의 값은 그대로다. 바깥 변수와 값을 계속 공유하고 싶을 때만 use (&$called) 처럼 참조로 가져온다. 참조 캡처는 클로저를 호출할 때마다 바깥 변수가 바뀌므로, 호출 횟수를 세는 정도의 좁은 용도에 쓰는 편이 읽기 쉽다.
클로저는 Closure 클래스의 객체다. 그래서 타입 선언에 Closure 를 쓸 수 있고, 함수의 반환값이 될 수도 있다. 클로저를 반환하는 함수는 설정값을 미리 채워 둔 함수를 만드는 공장 역할을 한다. 예제의 makeDiscount(20) 은 20% 할인을 적용하는 함수를 돌려준다.
화살표 함수
fn 으로 시작하는 화살표 함수(arrow function)는 식 하나만 쓸 수 있는 짧은 클로저다. 본문의 식 값이 곧 반환값이므로 return 을 쓰지 않는다. 바깥 변수는 use 없이 자동으로 값 복사로 캡처된다.
$withArrow = fn(int $fee): int => intdiv($fee * (100 - $rate), 100);
자동 캡처는 편리하지만 한계가 있다. 본문에 문장을 여러 개 쓸 수 없고, 바깥 변수를 바꾸는 코드를 써도 바깥에는 반영되지 않는다. 캡처는 함수가 만들어질 때 한 번 일어나므로, 이후 바깥 변수가 바뀌어도 화살표 함수가 보는 값은 변하지 않는다. 두 형태의 차이를 표로 정리한다.
| 구분 | 일반 클로저 | 화살표 함수 | 비고 |
|---|---|---|---|
| 선언 | function () use (...) {} | fn() => 식 | 둘 다 Closure 객체 |
| 본문 | 문장 여러 개 가능 | 식 하나만 가능 | 값은 자동 반환 |
| 값 캡처 | use ($x) 로 명시 | 쓰는 변수를 자동 캡처 | 시점은 생성 때 |
| 참조 캡처 | use (&$x) 가능 | 불가 | 호출 횟수 세기 등 |
짧은 변환이나 조건이면 화살표 함수를, 여러 단계를 거치거나 참조 캡처가 필요하면 일반 클로저를 고른다. 화살표 함수가 두세 줄로 늘어나기 시작하면 이름 있는 함수로 빼는 것이 낫다.
first-class callable 문법
PHP 에서 이미 있는 함수를 콜백으로 넘길 때 오래된 방식은 함수 이름을 문자열로 쓰거나 [$객체, '메서드'] 배열을 쓰는 것이었다. 이 방식은 이름에 오타가 있어도 실행해 보기 전에는 모르고, 편집기가 이름을 따라가 주지 못한다. PHP 8.1 에서 들어온 first-class callable 문법(first-class callable syntax)은 호출 자리에 인자 대신 ... 을 쓰면 그 호출 대상을 Closure 로 만든다.
$upper = strtoupper(...); // 내장 함수
$won = Pricing::won(...); // 정적 메서드
$hasSeat = $seatBook->hasSeat(...); // 인스턴스 메서드
세 가지 모두 결과는 Closure 객체이고, 일반 클로저와 똑같이 호출하거나 array_map 에 넘길 수 있다. 인스턴스 메서드에서 만든 클로저는 그 객체를 계속 붙들고 있으므로, 객체의 비공개 상태도 그대로 쓴다. SeatBook 의 좌석 배열이 private 인데도 hasSeat(...) 클로저가 동작하는 이유다. 자세한 규칙은 공식 문서의 first-class callable 항목에서 확인할 수 있다.
이 문법은 생성자 호출 new Foo(...) 에는 쓸 수 없다. 객체를 만드는 클로저가 필요하면 fn() => new Foo() 로 감싼다.
array_map, array_filter, array_reduce 파이프라인
세 함수는 각각 변환, 선별, 접기를 맡는다. 콜백이 받는 인자와 결과의 모양이 다르므로 한 번 표로 정리해 두면 헷갈리지 않는다.
| 함수 | 콜백이 받는 값 | 콜백의 반환 | 결과 배열의 키 |
|---|---|---|---|
array_map | 원소 하나 | 변환된 값 | 원래 키를 유지 |
array_filter | 원소 하나 | 남길지 여부(bool) | 원래 키를 유지 |
array_reduce | 누적값, 원소 하나 | 새 누적값 | 배열이 아니라 값 하나 |
인자 순서도 다르다. array_map 은 콜백이 먼저이고 array_filter 와 array_reduce 는 배열이 먼저다. array_filter 는 키를 지우지 않으므로, 숫자 키가 연속이라고 가정하는 코드에는 array_values 를 거쳐 넘겨야 한다.
이 세 함수는 결과 배열을 전부 메모리에 만든다. 앞 장의 제너레이터처럼 한 건씩 흘려 보내는 방식이 아니므로, 수십만 건 이상을 다루는 입력에는 제너레이터에서 읽어 한 건씩 처리하는 쪽이 맞다. 수강 신청처럼 한 번에 수십~수천 건인 데이터에는 배열 함수가 간결하다.
그림처럼 앞 단계의 출력이 다음 단계의 입력이 되는 구조를 파이프라인(pipeline)이라 한다. 단계를 함수 값으로 만들어 두면 파이프라인 자체도 array_reduce 로 만들 수 있다. 각 단계를 순서대로 적용하는 것은 "누적값을 함수에 통과시키는" 접기 연산과 같다. 예제의 pipe() 가 그 구현이다.
고차 함수로 검증 규칙 조합
고차 함수(higher-order function)는 함수를 받거나 함수를 돌려주는 함수다. 검증 규칙의 형태를 하나로 정한다. 신청서를 받아 오류 메시지를 문자열로 돌려주고, 통과하면 null 을 돌려주는 클로저다. 이 약속만 지키면 규칙을 만드는 함수와 규칙을 합치는 함수를 모두 고차 함수로 쓸 수 있다.
| 함수 | 받는 것 | 돌려주는 것 | 역할 |
|---|---|---|---|
fromPredicate | 조건 함수, 메시지 | 규칙 | 참/거짓 조건을 규칙으로 바꾼다 |
onlyIf | 적용 조건, 규칙 | 규칙 | 조건이 맞을 때만 규칙을 적용한다 |
collectErrors | 규칙 여러 개 | 검증 함수 | 모든 오류 메시지를 배열로 모은다 |
pipe | 단계 여러 개 | 함수 하나 | 단계를 순서대로 이어 붙인다 |
fromPredicate 가 중요한 이유는 규칙을 쓰는 사람이 오류 메시지 처리를 반복하지 않게 해 주기 때문이다. 조건만 불리언으로 쓰면 메시지 변환은 이 함수가 맡는다. onlyIf 는 코딩 강좌에만 적용되는 나이 제한처럼 조건부 규칙을 기존 규칙을 고치지 않고 감싸서 만든다. 규칙을 새로 추가하는 일은 배열에 원소 한 줄을 넣는 일로 끝난다.
완성 코드
아래 코드는 하나의 파일로 동작한다. 외부 패키지 없이 내장 기능만 쓴다.
main.php
<?php
declare(strict_types=1);
final class Application
{
public function __construct(
public readonly string $student,
public readonly string $course,
public readonly int $age,
public readonly int $fee,
) {
}
}
final class Pricing
{
public static function won(int $amount): string
{
return number_format($amount) . '원';
}
}
final class SeatBook
{
/** @param array<string, int> $seats */
public function __construct(private array $seats)
{
}
public function hasSeat(Application $application): bool
{
return ($this->seats[$application->course] ?? 0) > 0;
}
}
function makeDiscount(int $percent): Closure
{
return fn(int $fee): int => intdiv($fee * (100 - $percent), 100);
}
function fromPredicate(Closure $test, string $message): Closure
{
return fn(Application $a): ?string => $test($a) ? null : $message;
}
function onlyIf(Closure $condition, Closure $rule): Closure
{
return fn(Application $a): ?string => $condition($a) ? $rule($a) : null;
}
function collectErrors(Closure ...$rules): Closure
{
return fn(Application $a): array => array_values(array_filter(
array_map(fn(Closure $rule): ?string => $rule($a), $rules),
fn(?string $message): bool => $message !== null,
));
}
function pipe(Closure ...$steps): Closure
{
return fn(mixed $input): mixed => array_reduce(
$steps,
fn(mixed $carry, Closure $step): mixed => $step($carry),
$input,
);
}
$apps = [
new Application('김하늘', 'piano', 9, 80000),
new Application('', 'piano', 10, 80000),
new Application('박도윤', 'coding', 6, 120000),
new Application('이서연', 'coding', 12, 120000),
new Application('최민준', 'art', 8, 90000),
];
$seatBook = new SeatBook(['piano' => 5, 'coding' => 1, 'art' => 0]);
echo '[1] 클로저와 화살표 함수', PHP_EOL;
$rate = 10;
$withUse = function (int $fee) use ($rate): int {
return intdiv($fee * (100 - $rate), 100);
};
$withArrow = fn(int $fee): int => intdiv($fee * (100 - $rate), 100);
$rate = 50;
echo 'use: ', $withUse(100000), ', fn: ', $withArrow(100000), PHP_EOL;
$discount20 = makeDiscount(20);
echo '20% 할인: ', $discount20(100000), PHP_EOL;
echo '[2] first-class callable', PHP_EOL;
$upper = strtoupper(...);
echo $upper('piano'), PHP_EOL;
$won = Pricing::won(...);
echo $won(80000), PHP_EOL;
echo implode(', ', array_map($won, [80000, 120000])), PHP_EOL;
$hasSeat = $seatBook->hasSeat(...);
echo $hasSeat($apps[0]) ? '좌석 있음' : '좌석 없음', PHP_EOL;
echo '[3] 검증 규칙 조합', PHP_EOL;
$rules = [
fromPredicate(fn(Application $a): bool => trim($a->student) !== '', '이름이 비어 있다'),
fromPredicate(fn(Application $a): bool => $a->age >= 7, '7세 이상만 신청할 수 있다'),
onlyIf(
fn(Application $a): bool => $a->course === 'coding',
fromPredicate(fn(Application $a): bool => $a->age >= 8, '코딩 강좌는 8세 이상이다'),
),
fromPredicate($seatBook->hasSeat(...), '잔여 좌석이 없다'),
fromPredicate(fn(Application $a): bool => $a->fee > 0, '수강료가 올바르지 않다'),
];
$validate = collectErrors(...$rules);
foreach ($apps as $app) {
$label = $app->student === '' ? '(이름 없음)' : $app->student;
$errors = $validate($app);
echo $label, ' / ', $app->course, ': ',
$errors === [] ? '통과' : implode('; ', $errors), PHP_EOL;
}
echo '[4] 파이프라인', PHP_EOL;
$called = 0;
$tracked = function (Application $a) use ($validate, &$called): bool {
$called++;
return $validate($a) === [];
};
$passed = array_filter($apps, $tracked);
echo '통과 신청 번호: ', implode(',', array_keys($passed)), PHP_EOL;
echo '검사 횟수: ', $called, PHP_EOL;
$report = pipe(
fn(array $list): array => array_filter(
$list,
fn(Application $a): bool => $validate($a) === [],
),
fn(array $list): array => array_map(fn(Application $a): int => $a->fee, $list),
fn(array $fees): int => array_reduce($fees, fn(int $sum, int $fee): int => $sum + $fee, 0),
makeDiscount(10),
Pricing::won(...),
);
echo '할인 후 총액: ', $report($apps), PHP_EOL;
줄별 해설
클래스 세 개
Application 은 신청서 한 건을 읽기 전용 속성으로 담는다. Pricing::won 은 숫자를 천 단위 쉼표와 단위가 붙은 문자열로 바꾸는 정적 메서드이고, 뒤에서 first-class callable 의 재료가 된다. SeatBook 은 강좌별 잔여 좌석을 비공개 배열로 들고 있으며, hasSeat 는 없는 강좌를 0석으로 보는 ?? 처리를 한다.
고차 함수 다섯 개
makeDiscount 는 퍼센트를 받아 화살표 함수를 돌려준다. 화살표 함수가 $percent 를 자동으로 캡처하므로 use 가 필요 없다. fromPredicate 는 조건 함수와 메시지를 받아 규칙으로 바꾼다. 삼항 연산자의 결과가 null 이면 통과, 문자열이면 오류다. onlyIf 는 조건이 참일 때만 안쪽 규칙을 호출하고, 아니면 통과로 본다.
collectErrors 는 가변 인자로 규칙들을 받는다. 반환하는 함수는 먼저 array_map 으로 모든 규칙을 신청서에 적용해 ?string 배열을 만들고, array_filter 로 null 이 아닌 것만 남기고, array_values 로 키를 0부터 다시 매긴다. 규칙이 첫 오류에서 멈추지 않고 모든 규칙을 평가하므로 오류가 한 번에 모인다. pipe 는 단계들을 array_reduce 로 접는다. 초기값이 입력이고, 단계마다 누적값이 그 단계 함수를 통과한다.
[1] 클로저와 화살표 함수
$rate 를 10 으로 둔 뒤 use 클로저와 화살표 함수를 만들고, 그다음 $rate 를 50 으로 바꾼다. 두 함수 모두 만들어진 시점의 10 을 갖고 있으므로 100000 의 10% 할인인 90000 이 나온다. 만약 바깥 변수를 따라갔다면 50000 이 나왔을 것이다. makeDiscount(20) 이 돌려준 함수는 20% 를 기억하고 있다.
[2] first-class callable
strtoupper(...) 는 내장 함수를, Pricing::won(...) 는 정적 메서드를, $seatBook->hasSeat(...) 는 특정 객체에 묶인 메서드를 클로저로 만든다. array_map($won, ...) 처럼 변수에 담긴 클로저를 바로 넘기면 문자열 이름이 필요 없다.
[3] 검증 규칙 조합
규칙 배열은 다섯 개다. 세 번째는 onlyIf 로 감싸서 코딩 강좌일 때만 8세 이상을 요구한다. 네 번째는 화살표 함수 대신 hasSeat(...) 클로저를 그대로 조건으로 넘긴다. collectErrors(...$rules) 의 ... 는 배열을 인자로 펼치는 연산이며, first-class callable 의 (...) 와는 모양만 비슷하고 다른 문법이다. 박도윤은 6세이면서 코딩 강좌를 신청했으므로 두 규칙에 모두 걸려 메시지가 둘 나온다.
[4] 파이프라인
$tracked 는 use 에 $validate 는 값으로, $called 는 참조로 넣은 일반 클로저다. array_filter 가 신청서 5건을 모두 한 번씩 검사하므로 호출 횟수는 5 다. 통과한 건은 0번과 3번이고, 필터링 후에도 원래 키가 남아 있는 것을 키 출력으로 확인한다. $report 는 필터, 변환, 합계, 10% 할인, 문자열 변환을 이어 붙인 함수다. 통과한 두 건의 수강료 합 200000 에 10% 할인을 적용해 180000 이 되고, Pricing::won 이 이를 문자열로 바꾼다.
실행 결과
$ php main.php
[1] 클로저와 화살표 함수
use: 90000, fn: 90000
20% 할인: 80000
[2] first-class callable
PIANO
80,000원
80,000원, 120,000원
좌석 있음
[3] 검증 규칙 조합
김하늘 / piano: 통과
(이름 없음) / piano: 이름이 비어 있다
박도윤 / coding: 7세 이상만 신청할 수 있다; 코딩 강좌는 8세 이상이다
이서연 / coding: 통과
최민준 / art: 잔여 좌석이 없다
[4] 파이프라인
통과 신청 번호: 0,3
검사 횟수: 5
할인 후 총액: 180,000원
실무에서 자주 틀리는 것
1. use 로 가져온 변수가 바뀌기를 기대한다
값 캡처는 복사본을 만든다. 클로저 안에서 증가시켜도 바깥은 그대로다. 화살표 함수도 마찬가지다.
// 틀린 코드
$count = 0;
$tick = function () use ($count): void {
$count++;
};
$tick();
$tick();
echo $count; // 0
// 고친 코드
$count = 0;
$tick = function () use (&$count): void {
$count++;
};
$tick();
$tick();
echo $count; // 2
참조 캡처는 바깥 상태를 몰래 바꾸므로 읽는 사람이 놓치기 쉽다. 가능하면 값을 반환하고 호출하는 쪽에서 모으는 방식을 먼저 고려한다.
2. array_filter 결과를 연속된 번호로 접근한다
array_filter 는 남은 원소의 원래 키를 유지한다. 앞쪽 원소가 걸러지면 0번 키가 사라진다.
// 틀린 코드
$passed = array_filter($apps, fn(Application $a): bool => $validate($a) === []);
echo $passed[1]->student; // 1번 키가 없어 경고가 난다
// 고친 코드
$passed = array_values(array_filter($apps, fn(Application $a): bool => $validate($a) === []));
echo $passed[1]->student; // 두 번째로 통과한 신청자
3. 콜백 없이 array_filter 를 호출한다
콜백을 생략하면 PHP 의 거짓 값 기준으로 걸러진다. 값이 0 인 정상 데이터도 함께 사라진다.
// 틀린 코드: 0 도 빠진다
$fees = [80000, null, 0];
$valid = array_filter($fees); // [0 => 80000]
// 고친 코드: 지우려는 조건을 명시한다
$fees = [80000, null, 0];
$valid = array_filter($fees, fn(?int $fee): bool => $fee !== null); // [0 => 80000, 2 => 0]
4. array_reduce 의 초기값을 빼먹는다
초기값을 생략하면 빈 배열에서 결과가 null 이 된다. 반환 타입이 int 인 함수에서 타입 오류로 이어진다.
// 틀린 코드
function sumFees(array $fees): int
{
return array_reduce($fees, fn(int $sum, int $fee): int => $sum + $fee);
}
sumFees([]); // TypeError: 반환값이 null
// 고친 코드
function sumFees(array $fees): int
{
return array_reduce($fees, fn(int $sum, int $fee): int => $sum + $fee, 0);
}
sumFees([]); // 0
한눈에 보기
| 도구 | 형태 | 쓰는 때 | 주의 |
|---|---|---|---|
| 클로저 | function () use ($x) {} | 여러 문장이 필요하거나 참조 캡처가 필요할 때 | 값 캡처는 복사본이다 |
| 화살표 함수 | fn($a) => 식 | 짧은 변환·조건 | 식 하나, 바깥 변수 수정 불가 |
| first-class callable | f(...), $o->m(...), C::m(...) | 이미 있는 함수를 콜백으로 넘길 때 | new 에는 쓸 수 없다 |
array_map | 콜백, 배열 | 원소를 일대일로 변환 | 키를 유지한다 |
array_filter | 배열, 콜백 | 조건에 맞는 것만 남김 | 키 유지, 콜백 생략 주의 |
array_reduce | 배열, 콜백, 초기값 | 합계·조립·함수 이어 붙이기 | 초기값을 항상 쓴다 |
| 고차 함수 | 함수를 받거나 반환 | 규칙 조합, 설정값을 채운 함수 생성 | 규칙의 모양을 하나로 통일한다 |
연습 문제
- 다음 코드가 출력하는 값을 쓰고 이유를 설명하라.
$n = 1; $a = fn(): int => $n++; $a(); $a(); echo $n; maxFee(int $limit)함수를 만들어라. 수강료가$limit을 넘으면 '수강료가 한도를 넘는다' 를 돌려주는 규칙을 반환해야 하며,fromPredicate를 써야 한다.array_map('trim', $names)를 first-class callable 문법으로 고쳐 쓰고, 문자열 이름 방식과 비교해 얻는 이점을 한 가지 말하라.- 예제의
$apps에서 검증을 통과한 신청자의 이름만 담긴 배열(키는 0부터 연속)을 만드는 코드를array_filter,array_map,array_values로 작성하라.
정답과 해설
1. 출력은 1 이다. 화살표 함수는 만들어질 때 $n 의 값 1 을 복사해 두고, 호출할 때마다 그 복사본을 증가시킬 뿐 바깥 $n 은 건드리지 않는다. 바깥 변수를 바꾸려면 function () use (&$n) 형태의 일반 클로저가 필요하다.
2.
function maxFee(int $limit): Closure
{
return fromPredicate(
fn(Application $a): bool => $a->fee <= $limit,
'수강료가 한도를 넘는다',
);
}
화살표 함수가 $limit 을 자동 캡처하고, fromPredicate 가 조건을 규칙으로 바꾼다. 규칙 배열에 maxFee(150000) 한 줄을 넣으면 적용된다.
3. array_map(trim(...), $names) 로 쓴다. 이름을 문자열이 아닌 코드로 쓰므로 오타가 있으면 해당 호출 시점에 바로 오류가 나고, 편집기가 함수 정의로 이동하는 기능도 정상 동작한다. 메서드를 넘길 때는 [$obj, 'name'] 배열보다 $obj->name(...) 이 짧고 안전하다.
4.
$names = array_values(array_map(
fn(Application $a): string => $a->student,
array_filter($apps, fn(Application $a): bool => $validate($a) === []),
));
array_map 은 키를 유지하므로 결과 키는 0, 3 이다. 마지막에 array_values 를 거쳐야 0, 1 로 정리된다. 예제 데이터에서는 ['김하늘', '이서연'] 이 된다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.