안에서 밖으로 읽던 함수 중첩을, 위에서 아래로 — PHP 8.5 파이프 연산자 |>

  • 8th September 2026
  • 7 min read

입력값을 다듬는 코드는 대개 이런 모양이 된다.

$amount = intval(preg_replace('/[^0-9]/', '', str_replace(',', '', trim($raw))));

실행은 trim부터다. 그 다음 str_replace, preg_replace, intval 순서로 간다. 그런데 눈은 정반대로 움직인다. 왼쪽 끝의 intval을 먼저 만나고, 괄호를 따라 안쪽으로 파고들어 trim에 닿은 다음, 거기서 다시 밖으로 나오면서 순서를 되짚어야 한다.

그래서 보통은 임시 변수로 푼다.

$trimmed  = trim($raw);
$noComma  = str_replace(',', '', $trimmed);
$digits   = preg_replace('/[^0-9]/', '', $noComma);
$amount   = intval($digits);

읽는 순서는 바로잡혔지만 쓸모없는 이름을 셋 지어야 했다. $trimmed, $noComma, $digits는 다음 줄에서 한 번 쓰이고 사라진다. 그렇다고 $raw에 계속 덮어쓰면, 아래쪽에서 $raw를 볼 때 그게 어느 단계의 값인지 알 수 없게 된다.

PHP 8.5(2025년 11월 20일 출시)의 파이프 연산자는 이 셋 중 어느 쪽도 고르지 않게 해준다.

$amount = $raw
    |> trim(...)
    |> (fn($s) => str_replace(',', '', $s))
    |> (fn($s) => preg_replace('/[^0-9]/', '', $s))
    |> intval(...);

왼쪽 값을 오른쪽 함수의 인자로 넘기고, 그 결과를 다시 다음으로 넘긴다. 실행 순서와 읽는 순서가 같아지고 중간 이름도 필요 없다. 위의 세 판본은 모두 같은 값을 낸다(PHP 8.5.4에서 확인).

중첩 호출 — 실행 순서가 안쪽에서 바깥쪽으로 ucfirst(strtolower(trim($name))) 눈이 되짚는 방향 — 안에서 밖으로 파이프 — 실행 순서가 위에서 아래로 $name |> trim(...) |> strtolower(...) |> ucfirst(...) 읽는 방향과 실행 방향이 같다
같은 세 단계를 두 방식으로 — 번호는 실행 순서다

오른쪽에 올 수 있는 것

파이프 오른쪽은 값 하나를 받는 호출 가능한 것이면 된다. 형태는 꽤 넓다. PHP 8.5.4에서 실제로 돌려 본 것들이다.

'ab' |> strtoupper(...)          // 일급 콜러블 — 가장 흔한 형태
'ab' |> 'strtoupper'             // 함수 이름 문자열
'ab' |> $fn                      // 클로저를 담은 변수
'ab' |> [$obj, 'method']         // 배열 콜러블
'ab' |> $obj->method(...)        // 메서드
'ab' |> Formatter::clean(...)    // 정적 메서드
'ab' |> (fn($s) => $s . '!')     // 화살표 함수 — 괄호가 필요하다

마지막 줄의 괄호는 선택이 아니다. 빼면 실행 전에 막힌다.

PHP Fatal error: Arrow functions on the right hand side of |> must be parenthesized

괄호가 없으면 fn($x) => $x * 2 |> abs(...)에서 파이프가 화살표 함수의 몸통에 속하는지 바깥에 속하는지 알 수 없다. 언어가 추측하는 대신 오류로 못 박았다. 메시지가 무엇을 하라는지 그대로 말해주므로 헤맬 일은 없다.

함정 1 — 값을 받는 자리가 첫 번째 인자여야 한다

파이프는 왼쪽 값을 첫 번째 인자로 넣는다. 그러니 필수 인자가 둘 이상인 함수는 그대로 붙지 않는다.

'a,b' |> explode(...);
// ArgumentCountError: explode() expects at least 2 arguments, 1 given

문제는 PHP 표준 라이브러리가 이 점에서 일관적이지 않다는 것이다. 같은 배열 함수인데도 갈린다.

array_filter(array $array, ?callable $callback = null, int $mode = 0)
array_map(?callable $callback, array $array, array ...$arrays)

array_filter는 배열이 첫 인자라 $rows |> array_filter(...)가 바로 된다. array_map은 콜백이 첫 인자라 안 된다. 쓰는 사람 입장에서 둘은 짝처럼 느껴지는데 파이프 앞에서는 정반대로 갈린다.

표준 함수를 필수 인자 개수로 갈라 보면 이렇다.

그대로 붙는 것 (필수 인자 1개) 감싸야 하는 것 (필수 인자 2개 이상)
trim · strtolower · ucfirst · strlen · htmlspecialchars · intval · number_format · json_decode · str_split str_replace(3) · preg_replace(3) · explode(2) · in_array(2) · str_pad(2) · array_search(2)
array_filter · array_sum · array_unique · array_reverse · array_keys · count · implode array_map(2) · array_column(2)

오른쪽 열은 화살표 함수로 감싸면 된다. 이때 흘려보내는 값이 들어갈 자리만 매개변수로 남기고 나머지는 고정한다.

$rows
    |> (fn($a) => array_map(trim(...), $a))
    |> array_filter(...)
    |> array_values(...);

다만 감싸는 것이 많아지면 파이프의 이점이 줄어든다. 줄마다 (fn($x) => …)가 붙기 시작하면 중첩 호출보다 나을 게 없다. 파이프가 제값을 하는 자리는 값 하나를 그대로 통과시키는 변환이 줄줄이 이어질 때다.

함정 2 — 참조로 받는 함수는 아예 못 쓴다

배열을 정렬하고 싶어서 이렇게 쓰면 실패한다.

$list |> sort(...);
// Error: sort(): Argument #1 ($array) could not be passed by reference

RFC가 명시적으로 금지한 부분이다 — "참조로 전달하는 콜러블은 파이프 연산자의 오른쪽에서 허용되지 않는다." 파이프를 값이 흘러가는 순수 함수적인 방식으로 설계했기 때문이다. 원본을 제자리에서 바꾸는 함수는 그 그림에 맞지 않는다.

정렬이 필요하면 복사본을 만들어 돌려주는 형태로 감싼다.

$sorted = $list
    |> array_values(...)
    |> (function (array $a) { sort($a); return $a; });

번거로워 보이지만 결과적으로 나쁘지 않다. 파이프 중간에 원본을 건드리는 단계가 끼어 있으면 어차피 읽는 사람이 값의 흐름을 신뢰할 수 없다.

우선순위 — 계산보다 늦고 비교보다 이르다

파이프는 산술 연산보다 늦게, 비교 연산보다 먼저 묶인다. 왼쪽 결합이다. 실제로 재 보면 이렇다.

쓴 것 묶이는 방식
5 + 2 |> $f(5 + 2) |> $f — 7이 넘어간다
-2 |> abs(...)(-2) |> abs(...) — 결과 2
'a' . 'b' |> strtoupper(...)('a' . 'b') |> strtoupper(...) — 'AB'
'beep' |> strlen(...) == 4('beep' |> strlen(...)) == 4 — true
$v |> $f ?? '기본값'($v |> $f) ?? '기본값' — 체인 전체에 걸린다

대체로 직관과 맞는다. 값을 다 계산해서 파이프에 넣고, 파이프가 끝난 결과를 비교하거나 기본값으로 받는 순서다. 파이프를 쓰면서 우선순위 때문에 괄호를 더 치게 되는 일은 드물다.

디버깅 — 여러 줄로 쓰면 실패한 단계가 남는다

이건 파이프의 부수 효과인데 실제로는 꽤 크다. 세 단계 중 가운데가 예외를 던지게 만들고 스택 트레이스의 호출 지점을 확인해 보면 이렇다.

쓰는 방식 트레이스가 가리키는 줄
중첩 호출 ok2(bad(ok1($x)))전체가 놓인 한 줄
파이프를 한 줄에전체가 놓인 한 줄
파이프를 여러 줄에실패한 단계가 적힌 그 줄

중첩 호출은 어느 단계에서 터졌든 트레이스가 같은 줄을 가리킨다. 단계가 다섯이면 다섯 중 어느 것인지 트레이스만으로는 모른다. 파이프를 한 단계씩 줄을 나눠 쓰면 그 줄 번호가 그대로 남는다. 로그만 보고 어느 변환에서 깨졌는지 알 수 있다는 뜻이다.

중간값을 들여다보고 싶을 때는 값을 그대로 돌려주는 단계를 하나 끼우면 된다.

$tap = fn(callable $peek) => function ($v) use ($peek) { $peek($v); return $v; };

$result = $raw
    |> trim(...)
    |> $tap(fn($v) => error_log("정리 후: $v"))
    |> strtoupper(...);

파이프를 건드리지 않고 한 줄을 넣었다 뺐다 할 수 있다. 임시 변수 방식에서 var_dump를 넣던 자리를 이걸로 대신한다.

버전이 섞인 환경이라면 — 파싱부터 실패한다

가장 실무적인 제약이다. 파이프는 문법이라 PHP 8.5 미만에서는 파일이 열리지도 않는다. 8.3에서 돌려 보면 이렇다.

PHP Parse error: syntax error, unexpected token ">" in /tmp/pipe_test.php on line 1

중요한 것은 이게 실행 오류가 아니라 파싱 오류라는 점이다. 버전으로 갈라 쓰려고 해도 소용이 없다.

if (PHP_VERSION_ID >= 80500) {
    $r = $raw |> trim(...);   // 이 줄 때문에 파일 전체가 파싱 실패한다
} else {
    $r = trim($raw);
}

조건문은 실행 단계의 이야기고 파싱은 그 전에 끝난다. 8.3 서버에 이 파일이 올라가면 if가 거짓이든 참이든 상관없이 파일 전체가 죽는다. 함수 하나만 못 쓰는 게 아니라 그 파일에 있는 다른 코드까지 같이 죽는다.

그래서 여러 버전이 섞인 환경에서는 파이프를 쓴 코드를 파일 단위로 격리해야 한다. 8.5에서만 로드되는 파일에 두고, 그 파일을 require하는 쪽에서 버전을 확인한다. 라이브러리를 배포한다면 composer.jsonrequire"php": ">=8.5"를 올려야 하고, 그건 쓸 수 있는 사용자를 줄이는 결정이다. 애플리케이션 코드에서 버전을 통제할 수 있을 때 훨씬 편한 기능이다.

언제 쓰고 언제 두는가

파이프가 잘 맞는 모양은 뚜렷하다.

  • 값 하나가 여러 변환을 순서대로 통과할 때 — 입력 정리, 포맷 변환, 문자열 정규화
  • 중간 이름이 의미 없을 때$trimmed, $temp2 같은 이름밖에 안 나온다면
  • 단계가 대부분 그대로 붙을 때 — 감싸야 하는 단계가 절반을 넘으면 이점이 사라진다

반대로 두는 편이 나은 자리도 분명하다. 단계마다 인자가 여럿이면 화살표 함수가 줄마다 붙어 오히려 시끄러워진다. 중간값을 여러 번 다시 쓰는 흐름이면 애초에 이름 있는 변수가 맞다. 조건에 따라 단계를 건너뛰어야 한다면 파이프 안에서는 표현하기 어렵다.

그리고 메서드 체이닝이 이미 되는 객체라면 파이프는 필요 없다. $query->where(…)->orderBy(…)->get()은 같은 방향으로 이미 읽힌다. 파이프가 메우는 것은 객체가 아닌 값—문자열, 배열, 숫자—을 함수들에 통과시킬 때 생기던 빈자리다.

정리

  • 중첩 호출은 실행 순서와 읽는 순서가 반대다. 파이프는 그 둘을 같게 만든다. PHP 8.5(2025-11-20)부터.
  • 오른쪽에는 값 하나를 받는 콜러블이면 무엇이든 온다. 화살표 함수만 괄호가 필수다.
  • 흘려보내는 값은 첫 번째 인자로 들어간다. 필수 인자가 둘 이상인 함수는 감싸야 한다 — array_filter는 붙고 array_map은 안 붙는 식으로 표준 라이브러리가 갈린다.
  • 참조로 받는 함수는 금지다. sort 같은 것은 복사본을 돌려주는 형태로 감싼다.
  • 우선순위는 산술보다 늦고 비교보다 이르다. 괄호를 더 칠 일은 드물다.
  • 여러 줄로 쓰면 실패한 단계의 줄 번호가 트레이스에 남는다. 중첩 호출에는 없던 이점이다.
  • 8.5 미만에서는 파싱부터 실패한다. 버전 분기로 피할 수 없으니 파일 단위로 격리한다.

참고