about_PSItem

about_PSItem

파이프라인을 다루다 보면 "지금 처리하고 있는 이 객체가 뭔지"를 코드로 알아야 할 때가 많아요. PowerShell은 그 순간을 위해 현재 파이프라인 객체를 가리키는 자동 변수를 두 개 준비해 뒀는데, 바로 $_$PSItem이에요.

$PSItem은 변수 이름이 더 명확해 보이도록 나중에 추가된 거예요. 그런데 실제 현장에서는 달러 사인에 밑줄이 붙은 $_ 형태가 훨씬 많이 쓰여요. 이 글의 예제는 $PSItem으로 적어 두었지만, 모든 예제에서 $PSItem$_로 바꿔 써도 똑같이 동작해요. 그리고 권장 표기는 $_랍니다.

$PSItem이 등장하는 자리들을 먼저 훑어볼게요.

  • ForEach-Object cmdlet의 Process 매개변수 스크립트 블록
  • Where-Object cmdlet의 FilterScript 매개변수 스크립트 블록
  • 배열 내장 메서드인 ForEachWhere
  • 지연 바인딩(delay-bind) 스크립트 블록
  • switch 문의 조건값과 그에 딸린 문장 블록
  • 함수의 process 문 블록
  • filter 정의
  • ValidateScript 특성의 스크립트 블록
  • catch 문 블록
  • -replace 연산자의 치환(substitution) 스크립트 블록

이제 이 자리들을 예제와 함께 하나씩 살펴볼게요.

출처: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_psitem

본문

ForEach-Object의 Process 매개변수

ForEach-Object cmdlet은 파이프라인에 들어온 객체를 하나하나 처리하도록 만들어진 cmdlet이에요. 그래서 Process 매개변수의 스크립트 블록을 객체마다 한 번씩 실행해요.

$PSItemProcess 매개변수의 스크립트 블록에서는 쓸 수 있지만, Begin이나 End 매개변수 스크립트 블록에서는 쓸 수 없어요. 그 블록들은 파이프라인의 각 객체를 대상으로 동작하지 않기 때문에, 거기서 $PSItem을 참조하면 값이 $null이 돼요.

$parameters = @{
    Begin   = { Write-Host "PSItem in Begin is: $PSItem" }
    Process = {
        Write-Host "PSItem in Process is: $PSItem"
        $PSItem + 1
    }
    End     = { Write-Host "PSItem in End is: $PSItem" }
}

$result = 1, 2, 3 | ForEach-Object @parameters

Write-Host "Result is: $result"
PSItem in Begin is:
PSItem in Process is: 1
PSItem in Process is: 2
PSItem in Process is: 3
PSItem in End is:
Result is: 2 3 4

출력을 보면 Begin과 End에서는 $PSItem이 비어 있고, Process에서만 1, 2, 3이 하나씩 찍히는 걸 확인할 수 있어요. 즉 $PSItem을 쓰려면 반드시 Process 블록 안이어야 해요.

Where-Object의 FilterScript

Where-Object cmdlet은 파이프라인의 객체를 걸러 내는 역할을 해요. FilterScript 매개변수의 스크립트 블록은 파이프라인에 들어온 입력 객체마다 한 번씩 실행되니까, 그 안에서 $PSItem을 쓸 수 있어요.

1, 2, 3 | Where-Object -FilterScript { ($PSItem % 2) -eq 0 }
2

여기서 FilterScript는 현재 객체가 짝수인지를 확인해요. 그래서 홀수는 걸러 내고 원래 목록에서 2만 남겨 줘요.

ForEach 메서드와 Where 메서드

배열의 내장 메서드인 ForEachWhere는 둘 다 스크립트 블록을 입력으로 받아요. 그 스크립트 블록 안에서 $PSItem으로 현재 객체에 접근할 수 있답니다.

@('a', 'b', 'c').ForEach({ $PSItem.ToUpper() }).Where({ $PSItem -ceq 'B' })
B

이 예제에서는 ForEach 메서드의 스크립트 블록이 현재 객체를 대문자로 바꾸고, 그다음 Where 메서드가 그중 B만 돌려줘요.

지연 바인딩 스크립트 블록

지연 바인딩 스크립트 블록을 쓰면 파이프라인으로 들어오는 cmdlet을 실행하기 전에 $PSItem으로 매개변수 값을 미리 정해 둘 수 있어요.

dir config.log | Rename-Item -NewName { "old_$($_.Name)" }

switch 문

switch 문에서는 동작 스크립트 블록과 조건 스크립트 블록 양쪽에서 $PSItem을 쓸 수 있어요.

$numbers = 1, 2, 3

switch ($numbers) {
    { ($PSItem % 2) -eq 0 } { "$PSItem is even" }
    default { "$PSItem is odd" }
}
1 is odd
2 is even
3 is odd

이 예제의 조건 블록은 현재 객체가 짝수인지를 확인해요. 짝수면 그에 딸린 동작 블록이 "짝수"라는 메시지를, default 조건의 동작 블록은 "홀수"라는 메시지를 출력하지요.

함수의 process 문 블록

함수를 정의할 때 $PSItemprocess 블록 정의에서 쓸 수 있어요. 다만 begin이나 end 블록 정의에서는 못 써요. 거기서 참조하면 값이 $null이 되는데, 그 블록들은 파이프라인의 각 객체를 대상으로 동작하지 않기 때문이에요.

그리고 process 문 블록에서 $PSItem을 쓰면, 함수가 파이프라인에서 호출될 때는 현재 객체가 되고, 파이프라인 밖에서 호출되면 $null이 돼요.

function Add-One {
    process { $PSItem + 1 }
}

1, 2, 3 | Add-One
2
3
4

한 가지 알아두면 좋은 게 있어요. 고급 함수(advanced functions)에서도 $PSItem을 쓸 수는 있지만, 굳이 쓸 이유는 거의 없어요. 파이프라인에서 입력을 받으려는 거라면 Parameter 특성의 ValueFromPipeline이나 ValueFromPipelineByPropertyName 인자를 써서 매개변수를 정의하는 편이 낫답니다.

Parameter 특성과 cmdlet 바인딩을 쓰면 현재 객체를 직접 처리해서 값을 꺼내는 것보다 구현이 훨씬 명시적이고 예측 가능해져요. 고급 함수에서 $PSItem이 진짜 쓸모 있는 경우는, 파이프라인 입력을 받는 매개변수가 여러 개일 때 현재 객체 자체를 디버깅하거나 로깅용으로 들여다보는 경우 정도예요.

function Write-JsonLog {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]$Message
    )
    begin {
        $entries = @()
    }
    process {
        $entries += [pscustomobject]@{
            Message   = $Message
            TimeStamp = [datetime]::Now
        }

        if ($PSItem) {
            $props  = $PSItem | ConvertTo-Json
            $number = $entries.Length
            Write-Verbose "Input object $number is:`n$props"
        }
    }
    end {
        ConvertTo-Json -InputObject $entries
    }
}

이 함수는 메시지와 타임스탬프를 담은 JSON 객체 배열을 출력해요. 파이프라인에서 호출되면 항목마다 현재 객체의 Message 속성을 사용하지요. 그리고 현재 객체 자체의 JSON 표현을 verbose 스트림에 써 주기 때문에, 실제 입력과 출력 로그를 비교해 볼 수 있어요.

$Items = @(
    [pscustomobject]@{
        Name    = 'First Item'
        Message = 'A simple note'
    }
    [pscustomobject]@{
        Name    = 'Item with extra properties'
        Message = 'Missing message, has info instead'
        Info    = 'Some metadata'
        Source  = 'Where this came from'
    }
    [pscustomobject]@{
        Name    = 'Last Item'
        Message = 'This also gets logged'
    }
)

$Items | Write-JsonLog -Verbose
VERBOSE: Input object 1 is:
{
    "Name":  "First Item",
    "Message":  "A simple note"
}
VERBOSE: Input object 2 is:
{
    "Name":  "Item with extra properties",
    "Message":  "Missing message, has info instead",
    "Info":  "Some metadata",
    "Source":  "Where this came from"
}
VERBOSE: Input object 3 is:
{
    "Name":  "Last Item",
    "Message":  "This also gets logged"
}
[
    {
        "Message":  "A simple note",
        "TimeStamp":  "\/Date(1670344068257)\/"
    },
    {
        "Message":  "Missing message, has info instead",
        "TimeStamp":  "\/Date(1670344068259)\/"
    },
    {
        "Message":  "This also gets logged",
        "TimeStamp":  "\/Date(1670344068261)\/"
    }
]

filter 정의

filter 정의의 문장 목록에서도 $PSItem을 쓸 수 있어요. 함수의 process 블록과 같은 규칙이 적용되죠. filter가 파이프라인에서 호출되면 현재 객체가 되고, 그렇지 않으면 $null이에요.

filter Test-IsEven { ($PSItem % 2) -eq 0 }

1, 2, 3 | Test-IsEven
False
True
False

이 예제의 Test-IsEven filter는 현재 객체가 짝수면 $true를, 아니면 $false를 출력해요.

ValidateScript 특성의 스크립트 블록

ValidateScript 특성의 스크립트 블록에서도 $PSItem을 쓸 수 있어요. 이때 $PSItem은 지금 검증 중인 객체의 값이 됩니다. 변수나 매개변수 값이 배열이면, $PSItem을 현재 객체로 삼아 배열의 각 요소마다 스크립트 블록이 한 번씩 호출돼요.

function Add-EvenNumber {
    param(
        [ValidateScript({ 0 -eq ($PSItem % 2) })]
        [int[]]$Number
    )

    begin {
        [int]$total = 0
    }

    process {
        foreach ($n in $Number) {
            $total += $n
        }
    }

    end {
        $total
    }
}

Add-EvenNumber -Number 2, 4, 6

Add-EvenNumber -Number 1, 2
12

Add-EvenNumber:
Line |
  24 |  Add-EvenNumber -Number 1, 2
     |                         ~~~~
     | Cannot validate argument on parameter 'Number'. The
" 0 -eq ($PSItem % 2) " validation script for the argument
with value "1" did not return a result of True. Determine
why the validation script failed, and then try the command
again.

이 예제에서 ValidateScript 특성의 스크립트 블록은 Number 매개변수로 전달된 값마다 한 번씩 실행돼요. 짝수가 아닌 값이 섞여 있으면 오류를 돌려주지요. Add-EvenNumber 함수는 검증을 통과한 입력값들을 더한 총합을 돌려줍니다.

catch 문 블록

catch 문 블록 안에서는 $PSItem에 현재 오류가 담겨요. 그 객체의 형식은 ErrorRecord입니다.

try { NonsenseString }
catch {
    Write-Host "An error occurred:"
    Write-Host $PSItem
}

이 스크립트를 실행하면 아래 결과가 나와요.

An error occurred:
The term 'NonsenseString' is not recognized as the name of a cmdlet, function,
script file, or operable program. Check the spelling of the name, or if a path
was included, verify that the path is correct and try again.

예외 정보를 다루는 더 많은 예제는 about_Try_Catch_Finally 문서의 Accessing exception information 절을 참고하세요.

-replace 연산자의 치환 스크립트 블록

PowerShell 6부터 -replace 연산자를 호출하면서 치환 스크립트 블록을 정의할 때 $PSItem을 쓸 수 있어요. 이때 $PSItem의 값은 현재 매치된 값입니다.

$datePattern = '\d{4}-\d{2}-\d{2}'
'Today is 1999-12-31' -replace $datePattern, { [datetime]$PSItem.Value }
Today is 12/31/1999 00:00:00

이 예제의 치환 스크립트 블록은 매치된 날짜 문자열을 datetime으로 형 변환해서, 현재 문화권(culture)의 기본 형식으로 바꿔 줘요.

$PSItem의 값 바꾸기

$PSItem에 새 값을 할당해서 값 자체를 바꿀 수도 있어요. 다만 그렇게 하면 $PSItem에 의존하는 코드의 예상 동작이 달라질 수 있어요. 아래 예제를 볼게요. 원래라면 switch 문이 배열 $names의 모든 값을 처리해야 해요. 그런데 동작 문 블록 안에서 $PSItem 값을 바꿔 버려서, switch 문은 첫 번째 값만 처리하게 됩니다.

$names = 'Alice', 'Charlie'
switch ($names) {
    Alice   { "$PSItem says 'Hello!'"; $PSItem = 'Bob' }
    Bob     { "$PSItem says 'Goodbye.'"; $PSItem = 'Charlie'; break }
    Charlie { "$PSItem says 'How are you?'" }
}

switch 문이 첫 번째 값 Alice를 평가하면 첫 번째 조건에 매치되고, 그에 딸린 동작 블록이 실행돼요. 그 블록 안에서 $PSItem의 값이 Bob으로 바뀌면서, 이것이 switch 문의 평가에도 영향을 줍니다.

Alice says 'Hello!'
Bob says 'Goodbye.'

그렇기 때문에 $PSItem의 값을 바꾸는 일은 되도록 피하는 게 좋아요.

더 알아보기