PHP · 심층 가이드
PHP 함수형 완전 정리
배열의 값 의미론을 이용한 불변 업데이트, 함수 합성과 부분 적용, 제너레이터 지연 평가로 PHP에서 부작용 없는 데이터 변환 코드를 작성하는 방법을 다룹니다.
PHP는 함수형 언어가 아니지만 함수형 스타일과 궁합이 나쁘지 않습니다. 배열이 값으로 복사되기 때문에 $new = [...$old, 'x' => 1] 같은 표현이 원본을 건드리지 않고, 그래서 불변 업데이트를 위해 별도 라이브러리가 필요 없습니다. 부족한 쪽은 문법 편의입니다. 파이프 연산자도, 커링도, 표준 compose도 없어서 여러 변환을 이어 붙이려면 헬퍼를 직접 만들어야 합니다. 이 가이드의 절반은 그 빠진 조각을 손으로 채우는 연습입니다.
배열 불변 업데이트와 순수 포매터 함수로 부작용 없는 함수의 모양을 잡은 다음, map filter 체이닝에서 중첩 호출이 읽기 어려워지는 순간을 체감하고 함수 합성 헬퍼로 풀어내는 순서를 권합니다. 인자를 미리 고정하는 기법은 커링 흉내 내기와 부분 적용으로 prefix 함수 만들기가 같은 아이디어를 두 각도에서 보여 줍니다. 집계 쪽은 reduce 로 그룹핑이 축이고, 데이터가 커지면 제너레이터 파이프라인과 지연 평가로 큰 목록 처리로 같은 로직을 스트리밍으로 옮기게 됩니다.
체이닝의 대가는 메모리입니다. array_map과 array_filter는 매 단계마다 완전한 새 배열을 만들기 때문에, 단계가 네 개면 원본 크기의 배열이 네 벌 존재하는 순간이 생깁니다. 수십만 행짜리 CSV에서는 이것만으로 메모리 한도를 넘습니다. 실무에서 또 하나 성가신 건 인자 순서로, array_map(콜백, 배열)과 array_filter(배열, 콜백)이 서로 반대입니다. 합성 헬퍼를 만들 때 이 불일치를 흡수하는 얇은 래퍼를 먼저 정의해 두면 이후 코드가 훨씬 조용해집니다.
01배열 불변 업데이트
기존 배열을 스프레드로 펼치고 뒤에 role을 다시 적어 새 배열을 만든 뒤, 원본과 사본을 나란히 출력해 원본이 그대로임을 보여 줍니다. 상태를 덮어쓰지 않고 변경본을 넘기는 방식이라 디버깅이 쉬워집니다. 문자열 키 스프레드는 PHP 8.1부터 가능하고, 같은 키가 겹치면 뒤에 오는 값이 이깁니다.
PHP code
<?php
$user = ['name' => 'mida', 'role' => 'member'];
$updated = [...$user, 'role' => 'admin'];
print_r([$user, $updated]);정수 키 배열을 스프레드로 펼치면 키가 0부터 다시 매겨져 목록 합치기에 그대로 쓸 수 있습니다. 안쪽에 들어 있던 배열까지 새로 만들어지지는 않습니다.
겹치는 키를 앞에 두면 원본 값이 이겨서 수정이 반영되지 않습니다. 순서만 뒤집힌 실수라 오류 없이 옛 값이 그대로 남습니다.
02순수 포매터 함수
정수 금액을 받아 number_format()으로 천 단위 구분자를 넣고 단위 문자열을 덧붙여 돌려줍니다. 입력이 같으면 결과가 항상 같고 바깥 상태를 건드리지 않아 테스트가 한 줄로 끝납니다. number_format()은 기본이 소수점 0자리라 실수를 넘기면 조용히 반올림되므로, 통화 계산은 정수 단위로 유지하는 편이 낫습니다.
PHP code
<?php
function formatPrice(int $amount): string {
return number_format($amount) . ' KRW';
}
echo formatPrice(120000);두 번째 인자로 소수 자릿수를, 세 번째와 네 번째로 소수점과 천 단위 기호를 바꿀 수 있습니다. 통화 표기 규칙까지 필요하면 intl의 NumberFormatter를 봅니다.
표시용 문자열을 다시 계산에 넣으면 쉼표 때문에 숫자로 변환할 때 앞부분만 읽힙니다. 포맷과 계산은 반드시 분리해서 다뤄야 합니다.
03map filter 체이닝
안쪽 array_filter()가 홀수만 남기고, 그 결과를 array_map()이 10배로 키웁니다. 조건과 변환을 분리해 두면 각 단계를 따로 바꿔 끼울 수 있습니다. 주의할 점은 array_filter()가 원래 키를 그대로 보존한다는 것이라, 출력 키가 0·2·4로 남고 이 배열을 json_encode()하면 배열이 아닌 객체로 나갑니다. 필요하면 array_values()를 거칩니다.
PHP code
<?php
$numbers = [1, 2, 3, 4, 5];
$result = array_map(
fn (int $n): int => $n * 10,
array_filter($numbers, fn (int $n): bool => $n % 2 === 1)
);
print_r($result);필터를 먼저 걸어 원소 수를 줄인 뒤 변환하면 콜백 호출 횟수가 줄어듭니다. 두 단계 모두 원본을 건드리지 않아 중간 결과를 따로 확인하기 쉽습니다.
array_map이 앞 단계의 키를 그대로 물려받아 결과 키가 0, 2, 4로 남습니다. 이 배열을 JSON으로 내보내면 프런트엔드가 기대한 배열이 아니라 객체가 도착합니다.
04함수 합성 헬퍼
여러 콜러블을 받아 array_reverse()로 뒤집은 뒤 오른쪽부터 차례로 적용하는 클로저를 돌려줍니다. 문자열 정제처럼 정해진 순서의 변환을 이름 붙여 재사용할 때 쓰입니다. 반환되는 클로저가 $functions를 쓰려면 use로 명시해 붙잡아야 하며, 'trim' 같은 내장 함수 이름 문자열도 그대로 콜러블로 취급됩니다.
PHP code
<?php
function compose(callable ...$functions): Closure {
return function (mixed $value) use ($functions): mixed {
foreach (array_reverse($functions) as $fn) {
$value = $fn($value);
}
return $value;
};
}
$pipeline = compose('trim', 'strtolower');
echo $pipeline(' PHP ');compose는 단계 순서를 수학 표기처럼 오른쪽부터 읽어야 합니다. 왼쪽부터 읽히는 편이 낫다면 뒤집기를 빼고 파이프라는 이름으로 두는 쪽이 혼동이 적습니다.
단계마다 값이 mixed로 흘러 중간에 예상 밖 타입이 들어와도 그대로 진행되다 마지막에야 터집니다. 어느 단계에서 어긋났는지 트레이스만으로는 알기 어렵습니다.
05커링 흉내 내기
비교 대상을 미리 받아 두고, 나중에 넘어오는 값과 ===로 맞춰 보는 화살표 함수를 반환합니다. array_filter()나 usort()처럼 콜백만 받는 자리에 조건을 심어 넣을 때 편합니다. 화살표 함수는 use 없이 바깥 변수를 자동으로 끌어오지만 값 복사라, 만든 뒤에 $expected를 바꿔도 이미 만들어진 함수는 옛 값을 봅니다.
PHP code
<?php
function equals(string $expected): Closure {
return fn (string $actual): bool => $expected === $actual;
}
$isPhp = equals('php');
var_dump($isPhp('php'));이렇게 만든 클로저는 서로 다른 값을 붙잡은 독립 인스턴스라 조건별로 여러 개를 만들어 둘 수 있습니다. 함수로 이름을 붙이면 조건의 의도가 코드에 드러납니다.
화살표 함수는 바깥 변수를 값으로만 잡아 참조 캡처를 지원하지 않습니다. 나중에 조건을 바꿔 끼우려면 function과 use에 &를 붙여야 합니다.
06Option 비슷한 null 처리
값이 null이면 변환을 건너뛰고 그대로 돌려주며, 아니면 넘겨받은 콜러블을 적용합니다. 조회 결과가 있을 수도 없을 수도 있는 지점마다 if를 흩뿌리는 대신 한 곳으로 모을 수 있습니다. 검사 조건이 === null이라 빈 문자열이나 0은 변환 대상으로 통과하는데, ?:로 짠 코드와 결과가 갈리는 지점이 바로 여기입니다.
PHP code
<?php
function mapNullable(mixed $value, callable $mapper): mixed {
return $value === null ? null : $mapper($value);
}
echo mapNullable('php', 'strtoupper');mapper 자리에는 $obj->method(...) 문법으로 메서드도 그대로 넘길 수 있습니다. 여러 단계를 이어야 하면 이 함수를 중첩하는 대신 파이프 헬퍼로 묶는 편이 읽기 좋습니다.
mapper가 null을 돌려주면 원래 값이 없었는지 변환 결과가 null인지 구분할 수 없게 됩니다. 실패와 빈 값이 같은 의미가 아니라면 예외나 별도 타입으로 나눠야 합니다.
07제너레이터 파이프라인
numbers()가 값을 하나씩 내보내고 even()이 그 스트림을 받아 짝수만 다시 yield합니다. 소스와 필터를 각각 제너레이터로 두면 중간 배열 없이 단계를 이어 붙일 수 있습니다. 제너레이터는 한 번 소진하면 되감을 수 없어 같은 객체로 두 번 foreach를 돌면 예외가 나고, 재사용하려면 함수를 다시 호출해야 합니다.
PHP code
<?php
function numbers(): Generator {
yield from range(1, 5);
}
function even(Generator $source): Generator {
foreach ($source as $value) {
if ($value % 2 === 0) {
yield $value;
}
}
}
foreach (even(numbers()) as $value) {
echo $value . PHP_EOL;
}소비하는 쪽이 값을 요청할 때만 앞 단계가 진행되므로 파이프라인 전체가 한 원소씩 흐릅니다. 중간에 break로 멈추면 남은 값은 만들어지지도 않습니다.
결과를 변수에 담고 foreach를 두 번 돌리면 두 번째에서 이미 소진됐다는 예외가 납니다. 재사용하려면 제너레이터 함수를 다시 호출해야 합니다.
08reduce 로 그룹핑
array_reduce()의 누산기를 배열로 두고 category를 키로 삼아 제목을 밀어 넣어 중첩 구조를 만듭니다. SQL 결과처럼 평평한 행 목록을 화면 구조에 맞게 묶을 때 자주 쓰는 형태입니다. 콜백이 매번 배열을 반환해 낭비처럼 보이지만 PHP 배열은 쓰기 시점 복사라, 실제 복사는 값이 바뀔 때만 일어납니다.
PHP code
<?php
$rows = [
['category' => 'backend', 'title' => 'PHP'],
['category' => 'backend', 'title' => 'Go'],
['category' => 'frontend', 'title' => 'TypeScript'],
];
$grouped = array_reduce($rows, function (array $carry, array $row): array {
$carry[$row['category']][] = $row['title'];
return $carry;
}, []);
print_r($grouped);$carry[$key][]로 바로 밀어 넣을 수 있는 이유는 없는 키가 자동으로 빈 배열로 만들어지기 때문입니다. 그룹 순서는 카테고리가 처음 등장한 순서를 따릅니다.
콜백에서 return을 빼면 다음 반복의 누산기가 null이 되어 결과가 통째로 사라집니다. 누산기를 고치는 것만으로는 부족하고 반드시 돌려줘야 합니다.
09match 를 표현식으로 쓰기
match가 값을 비교해 곧바로 문자열을 내놓고, 그 결과가 return의 피연산자로 쓰입니다. 코드값을 라벨로 바꾸는 매핑을 임시 변수 없이 한 표현식에 담을 수 있습니다. switch와 달리 비교가 ===이고 폴스루가 없으며, 어느 분기에도 걸리지 않으면 default가 없는 한 UnhandledMatchError가 던져집니다.
PHP code
<?php
function badge(string $level): string {
return match ($level) {
'b' => 'beginner',
'i' => 'intermediate',
'a' => 'advanced',
default => 'unknown',
};
}
echo badge('i');match는 표현식이라 반환문뿐 아니라 인자 자리나 배열 값 자리에도 그대로 넣을 수 있습니다. 각 가지는 표현식 하나여야 해서 여러 줄 로직은 함수로 빼야 합니다.
라벨이 늘 때마다 이 함수를 고치게 되므로 값과 라벨을 함께 갖는 enum으로 옮기는 편이 낫습니다. 문자열 코드는 오타가 default로 조용히 흡수됩니다.
10부분 적용으로 prefix 함수 만들기
접두사를 붙잡은 화살표 함수를 만들어 두고, 이후에는 본문 문자열만 넘겨 접두사가 붙은 결과를 얻습니다. 채널별 로그 태그나 캐시 키 네임스페이스처럼 앞부분이 고정된 문자열 생성에 잘 맞습니다. fn은 표현식 하나만 담을 수 있고 캡처가 값 기준이라, 만들어진 뒤에 원본 변수를 바꿔도 이미 굳은 접두사는 따라오지 않습니다.
PHP code
<?php
function withPrefix(string $prefix): Closure {
return fn (string $value): string => $prefix . $value;
}
$logPrefix = withPrefix('[php] ');
echo $logPrefix('ready');이 방식은 로그 태그나 캐시 키처럼 같은 접두사를 반복해 넘기는 자리를 줄여 줍니다. 반환 타입을 Closure로 적어 두면 호출부에서 무엇이 돌아오는지 분명해집니다.
접두사에 사용자 입력이 들어오면 캐시 키가 예상 밖으로 갈라져 적중률이 떨어집니다. 키에 넣을 값은 길이와 문자 범위를 제한해야 합니다.
11DTO 리스트 변환
readonly 클래스와 생성자 프로퍼티 승격으로 정의한 DTO를 array_map()이 행마다 하나씩 찍어 냅니다. DB에서 올라온 배열을 그대로 여기저기 흘려보내는 대신 타입이 붙은 값 객체로 바꿔 두면 오타가 실행 전에 드러납니다. readonly 프로퍼티는 생성자 밖에서 다시 대입하면 Error가 나므로, 값을 바꾸려면 새 인스턴스를 만들어야 합니다.
PHP code
<?php
readonly class UserDto {
public function __construct(public int $id, public string $name) {}
}
$rows = [['id' => 1, 'name' => 'Kim'], ['id' => 2, 'name' => 'Lee']];
$dtos = array_map(fn (array $row): UserDto => new UserDto($row['id'], $row['name']), $rows);
print_r($dtos);행의 키가 DTO 파라미터 이름과 같다면 new UserDto(...$row)처럼 스프레드로 이름 있는 인자에 그대로 꽂을 수 있습니다. 타입이 어긋나면 그 자리에서 걸립니다.
드라이버에 따라 DB의 정수 컬럼이 문자열로 올라와 strict_types 환경에서 TypeError가 납니다. 매퍼 안에서 명시적으로 캐스팅해 두어야 합니다.
12결과 객체 비슷한 배열
나눗셈이 성공하면 ok와 value를, 0으로 나누면 ok와 error를 담은 배열을 돌려줍니다. 예외를 던지기엔 흔한 실패를 호출자가 분기로 다루게 하고 싶을 때의 절충안입니다. 분기마다 배열 키가 달라 value를 먼저 읽으면 정의되지 않은 키 경고를 맞으므로 반드시 ok를 먼저 확인해야 하며, 이 점 때문에 전용 결과 클래스가 더 안전합니다.
PHP code
<?php
function divide(int $a, int $b): array {
return $b === 0
? ['ok' => false, 'error' => 'division by zero']
: ['ok' => true, 'value' => $a / $b];
}
print_r(divide(10, 2));이 형태는 실패가 흔하고 호출부가 늘 처리하는 경우에 어울립니다. 키 이름을 상수로 두거나 enum과 조합하면 오타로 분기가 어긋나는 일을 줄일 수 있습니다.
반환 타입이 array라서 어떤 키가 오는지 시그니처만으로는 알 수 없고 IDE도 도와주지 못합니다. ok 확인을 빠뜨린 코드가 배포될 때까지 드러나지 않습니다.
13함수 목록 순차 적용
변환 단계들을 배열에 담고 array_reduce()가 앞 결과를 다음 단계 입력으로 넘기며 문자열을 슬러그로 만듭니다. 단계 추가나 순서 변경이 배열 편집만으로 끝나 설정으로 뽑아내기 좋습니다. 배열 안에는 내장 함수 이름 문자열과 화살표 함수가 섞여 있는데, PHP가 둘 다 콜러블로 보기 때문에 한 루프에서 그대로 호출됩니다.
PHP code
<?php
$steps = [
'trim',
'strtolower',
fn (string $value): string => str_replace(' ', '-', $value),
];
$value = array_reduce($steps, fn (string $carry, callable $step): string => $step($carry), ' Hello PHP ');
echo $value;단계 배열을 설정으로 빼면 코드 수정 없이 순서를 바꿀 수 있고 각 단계를 따로 테스트하기도 쉽습니다. 문자열로 적은 함수가 실제로 있는지 미리 확인해 두면 안전합니다.
존재하지 않는 함수 이름을 문자열로 넣으면 배열을 만들 때가 아니라 그 단계에 닿는 순간 Error가 납니다. 오타가 실행 경로를 타야만 드러납니다.
14지연 평가로 큰 목록 처리
요청받은 개수만큼 문자열을 만들되 배열에 쌓지 않고 yield로 하나씩 내보냅니다. 수십만 행 CSV나 끝을 모르는 스트림을 다룰 때 이 형태여야 메모리가 버팁니다. 대신 반환값은 배열이 아니라 Generator라 count()나 인덱스 접근이 되지 않고, 전체가 필요하면 iterator_to_array()로 펼치는 순간 메모리 이점이 사라집니다.
PHP code
<?php
function lines(int $count): Generator {
for ($i = 1; $i <= $count; $i++) {
yield "row-{$i}";
}
}
foreach (lines(3) as $line) {
echo $line . PHP_EOL;
}제너레이터는 순회를 마친 뒤 getReturn()으로 요약값을 따로 돌려줄 수 있습니다. 함수 인자로는 Generator 대신 iterable을 받아 두면 배열도 함께 받을 수 있습니다.
펼쳐 배열로 만드는 순간 메모리 이점이 사라지고, 키가 겹치면 원소까지 덮어써집니다. 개수만 필요하다면 iterator_count()를 쓰는 편이 낫습니다.
정리하며
- 원본을 수정하는 대신 스프레드로 새 배열을 만들어 함수의 부작용을 없앱니다.
- array_map과 array_filter의 인자 순서가 반대이니 래퍼로 통일해 두고 씁니다.
- 단계가 많은 변환은 배열 체이닝 대신 제너레이터 파이프라인으로 메모리를 고정합니다.
- 그룹핑, 집계, 분할은 reduce 하나로 표현하면 중간 배열을 만들지 않습니다.
더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.