본문으로 건너뛰기

PowerShell 다시 배우기 6 - 정규 표현식

PowerShell 다시 배우기 6/11

PowerShell의 정규 표현식은 .NET의 것이다. 문법은 .NET 문서를 따르고, PowerShell은 그것을 쓰기 편한 연산자와 명령으로 감싸 준다.

-match 와 $Matches

-match는 맞으면 True를 돌려주고, 맞은 내용을 자동 변수 $Matches에 넣는다.

if ('2026-10-04' -match '(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})') {
    $Matches.y
    $Matches
}
2026

Name                           Value
----                           -----
d                              04
m                              10
y                              2026
0                              2026-10-04

0은 맞은 전체다. 괄호에 (?<이름>...)처럼 이름을 붙이면 그 이름으로 꺼낼 수 있다. 번호로 세는 것보다 읽기 쉽고, 패턴을 고쳐도 번호가 밀리지 않는다.

알아 둘 것 둘.

  • $Matches는 마지막으로 성공한 비교의 결과다. 실패하면 바뀌지 않으므로, 반드시 if 안에서 쓴다.
  • 왼쪽이 배열이면 -match는 맞는 원소들을 돌려주고 $Matches는 채우지 않는다.

-match는 대소문자를 가리지 않는다. 가리려면 -cmatch를 쓴다.

-replace: 바꾸기

바꿀 문자열에서 $1, ${이름}으로 그룹을 가리킨다. PowerShell이 변수로 풀지 않게 작은따옴표를 쓴다.

'2026-10-04' -replace '(\d{4})-(\d{2})-(\d{2})', '$3/$2/$1'
# 04/10/2026

바꿀 내용을 계산해야 하면 스크립트 블록을 준다. $_는 맞은 부분이다. 2010년에는 [regex]::Replace()에 대리자를 넘겨야 했던 일이다.

'hello world' -replace '\b\w', { $_.Value.ToUpper() }
# Hello World

switch -Regex

여러 패턴 가운데 맞는 것을 고를 때는 switch가 깔끔하다. 배열을 주면 원소마다 돈다.

switch -Regex ('error: disk full', 'info: ok') {
    '^error' { "E: $_" }
    '^info'  { "I: $_" }
}
E: error: disk full
I: info: ok

Select-String: 파일에서 찾기

grep에 해당한다. 돌려주는 것은 글자가 아니라 파일 이름, 줄 번호, 줄 내용, 맞은 그룹을 담은 개체다.

Select-String -Path ./*.log -Pattern 'timeout after (\d+)ms' |
    ForEach-Object {
        [pscustomobject]@{
            File = $_.Filename
            Line = $_.LineNumber
            Ms   = [int]$_.Matches[0].Groups[1].Value
        }
    } |
    Sort-Object Ms -Descending

하위 폴더까지 찾으려면 Get-ChildItem -Recurse의 결과를 넘긴다.

Get-ChildItem -Recurse -Filter *.ps1 | Select-String 'Invoke-WebRequest'

예제: 글자로 된 목록을 개체로

2010년에 FTP 서버가 돌려주는 파일 목록을 정규식으로 풀어 개체로 바꾸는 글을 썼다. FTP는 이제 쓸 일이 드물지만, 글자로만 결과를 주는 도구를 다루는 방법으로는 지금도 그대로 통한다. 같은 일을 지금의 문법으로 쓰면 이렇다.

$listing = @'
drwxrwxrwx   10 1000     1000         4096 Jul 16 09:53 Storage
-rw-r--r--    1 1000     1000          179 Jun 16 21:19 examples.desktop
drwxr-xr-x    2 1000     1000         4096 Jun 16 21:29 문서
'@

$pattern = '^(?<type>[-dl])(?<mode>[-rwxs]{9})\s+\d+\s+(?<owner>\S+)\s+(?<group>\S+)\s+(?<size>\d+)\s+(?<date>\w{3}\s+\d+\s+[\d:]+)\s+(?<name>.+)$'

$listing -split '\r?\n' | ForEach-Object {
    if ($_ -match $pattern) {
        [pscustomobject]@{
            Name = $Matches.name
            Type = $Matches.type -eq 'd' ? 'Directory' : 'File'
            Size = [long]$Matches.size
            Date = $Matches.date
        }
    }
} | Sort-Object Size -Descending
Name             Type      Size Date
----             ----      ---- ----
Storage          Directory 4096 Jul 16 09:53
문서             Directory 4096 Jun 16 21:29
examples.desktop File       179 Jun 16 21:19

옛 코드와 견주면 세 가지가 달라졌다. 임시 파일에 적었다가 다시 읽던 것을 -split으로 바로 나눈다. New-Object PSObject는 [pscustomobject]가 됐다. if/else는 삼항 연산자가 됐다.

한 가지 조언. 도구가 JSON이나 CSV로 출력하는 옵션을 주면 정규식보다 그것을 먼저 쓴다. 정규식으로 화면용 출력을 푸는 것은 다른 길이 없을 때의 방법이다.

직접 .NET을 쓸 때

같은 패턴을 수만 번 쓰거나 맞는 것을 전부 찾아야 하면 [regex]를 직접 쓴다.

$text = 'a=1, b=22, c=333'
[regex]::Matches($text, '(\w)=(\d+)') | ForEach-Object {
    "$($_.Groups[1].Value) → $($_.Groups[2].Value)"
}

다음 글: 배열과 해시 테이블

이 글을 가리키는 글