PowerShell의 병렬 처리 ── ForEach-Object -Parallel과 작업(Job)의 구분 사용

· · PowerShell, Windows, 병렬 처리, 성능 개선, 자동화, 운영 개선, 스크립트, 작업(Job)

“200대의 PC에 ping을 날려 생사 확인하는 스크립트가 한 바퀴 도는 데 15분이 걸린다”, “공유 폴더 아래 10만 개 파일의 해시 계산이 끝나지 않는다” ── PowerShell 자동화가 어느 정도 성장하면 반드시 처리 시간의 벽에 부딪힙니다. 그리고 이런 처리의 대부분은 대기 시간이 지배적입니다. CPU는 한가한데 네트워크 응답이나 디스크 I/O를 한 건씩 순서대로 기다리느라 느립니다. 여기가 병렬화가 나설 차례입니다.

PowerShell 7에서는 ForEach-Object -Parallel을 사용할 수 있게 되어 병렬 처리의 장벽이 극적으로 낮아졌습니다. 다만 대충 병렬화하면 빨라지기는커녕 느려지고, 게다가 결과가 깨지는 까다로움도 있습니다. “공유 변수를 갱신했더니 값이 날아갔다”, “출력 순서가 매번 다르다”, “호출자에서 정의한 함수를 찾을 수 없다”는 모두 병렬 처리의 구조를 모른 채 사용했을 때 나타나는 전형적인 증상입니다.

이 글에서는 PowerShell에서 사용할 수 있는 병렬화 수단을 정리하고, ForEach-Object -Parallel의 사용법과 함정, Start-ThreadJob / Start-Job과의 구분 사용, 그리고 “병렬화해서는 안 되는 경우”까지를 실무에서 판단할 수 있는 형태로 정리합니다.

1. 먼저 결론

  • ForEach-Object -Parallel은 PowerShell 7.0에서 추가되었습니다. Windows PowerShell 5.1에는 존재하지 않습니다.1
  • 각 스크립트 블록은 별도의 런스페이스(실행 환경)에서 동작합니다. 호출자의 변수·함수는 그대로는 보이지 않습니다. 변수는 $using: 스코프 한정자로 전달합니다.1
  • -ThrottleLimit의 기본값은 5입니다. PowerShell 7.1 이후로는 런스페이스 풀이 재사용되며, ThrottleLimit이 그 풀 크기가 됩니다. 매번 새로 만들고 싶을 때는 -UseNewRunspace를 사용합니다.1
  • $using:으로 전달하는 것은 “참조”입니다. 읽기만 한다면 안전하지만, 갱신한다면 System.Collections.Concurrent의 스레드 안전한 타입을 사용해야 합니다. 일반 해시테이블이나 List의 동시 갱신은 깨집니다.1
  • 병렬화는 “반드시 빨라지는” 대책이 아닙니다. 공식 문서도 사소한 처리를 병렬화하면 평소보다 훨씬 느려질 수 있다고 명시하고 있습니다. 효과가 있는 것은 대기 시간이 긴 처리와 멀티코어에서 의미 있는 계산 처리입니다.1
  • 출력도 에러도 순서는 정해져 있지 않습니다. 에러 스트림에 쓰이는 순서도 무작위이며, 스크립트 블록 안의 종료 에러는 그 반복(iteration)만 멈추고 PSTaskException으로 보고됩니다.1
  • -AsJob으로 작업(job)으로 던질 수 있습니다. 다만 -ThrottleLimit은 작업 하나당 병렬 수이므로, 작업을 여러 개 만들면 동시 실행 수는 곱셈이 됩니다.1
  • 가벼운 병렬이라면 Start-ThreadJob, 격리가 필요하다면 Start-Job입니다. Start-Job은 별도 프로세스에서 동작하기 때문에 오버헤드가 크고, 결과는 직렬화되어 메서드를 잃은 상태로 돌아옵니다.23
  • 여러 대의 Windows에 같은 처리를 흘려보낸다면, Invoke-Command의 암묵적인 병렬 실행이 가장 빠릅니다. 이쪽은 기본값으로 32대까지 동시 실행합니다.4

2. 병렬화의 4가지 선택지

2.1 전제 지식 ── 런스페이스(runspace)란

이 글에서 반복해서 나오는 “런스페이스”를 먼저 짚어 둡니다. 런스페이스란 PowerShell이 스크립트를 실행하기 위한 “실행 환경” 그 자체입니다. 변수, 함수, 로드된 모듈, 현재 디렉터리 같은 세션 상태 일체가 이 런스페이스에 속합니다.

프로세스·스레드와의 관계로 말하면 다음과 같습니다.

계층 무엇인가 이 글에서의 의미
프로세스 OS에서 본 실행 단위(pwsh.exe 1개) 하나의 프로세스 안에 여러 개의 런스페이스를 가질 수 있음
스레드 실제로 코드를 실행하는 흐름 런스페이스는 스레드 위에서 동작함
런스페이스 PowerShell의 상태(변수·함수·모듈)를 담는 그릇 여기가 나뉘면 변수도 함수도 공유되지 않음

이미지로는 “하나의 프로세스 안에 변수도 함수도 별도로 가지는 PowerShell 세션을 여러 개 세울 수 있다“고 생각하면 가깝습니다. ForEach-Object -Parallel은 이 런스페이스를 여러 개 준비해서, 스크립트 블록을 각각 다른 스레드에서 실행하는 구조입니다.1

이 글의 골치 아픈 문제는 거의 다 여기서 나옵니다. 호출자의 변수와 함수는 호출자의 런스페이스에 속하기 때문에 다른 런스페이스에서는 보이지 않습니다. 그래서 변수는 $using:으로 명시적으로 전달해야 하고, 함수는 모듈화해서 각 런스페이스에서 불러와야 합니다. 반면 프로세스는 같기 때문에 메모리상의 객체 실체는 공유되어 버립니다. 이것이 뒤에서 다룰 스레드 안전성 문제로 되돌아옵니다.

“보이지 않는데 공유는 된다” ── 이 얼핏 모순된 성질이, 런스페이스를 이해하지 못한 채 병렬화했을 때 사고가 나는 원인입니다.

2.2 4가지 수단

먼저 지도를 갖춰둡니다. PowerShell에서 “동시에 실행”하는 수단은 크게 4가지입니다.

수단 실행 단위 사용 가능한 버전 적합한 용도
ForEach-Object -Parallel 스레드(런스페이스) PowerShell 7.0+1 컬렉션의 각 요소에 같은 처리. 제1후보
Start-ThreadJob 스레드(런스페이스) PowerShell 7 동봉 / 5.1은 Gallery에서 도입2 소수의 처리를 던져놓고 나중에 회수
Start-Job 별도 프로세스 5.1·7 모두 표준3 격리가 필요하거나 별도 프로세스로 만들고 싶은 처리
Invoke-Command -ComputerName 원격의 각 PC 5.1·7 모두 표준4 여러 대의 Windows에 같은 처리를 배포

실무상의 구분 사용은 단순합니다. 같은 처리를 대량의 대상에 적용한다면 ForEach-Object -Parallel, 대상이 “여러 대의 원격 PC”라면 원격 실행이 우선입니다. 후자는 지정한 여러 컴퓨터에 대해 기본값으로 32대까지 동시에 실행하기 때문에, 직접 병렬화를 작성할 필요가 없습니다.4 원격 실행 자체의 설정은 “PowerShell Remoting(WinRM) 입문“을 참조하세요.

Start-Job이 무거운 이유는 백그라운드 작업이 별도 프로세스로 시작되기 때문입니다. 프로세스 시작 비용에 더해 결과는 직렬화되어 돌아오므로, 받은 객체는 메서드가 없는 “역직렬화된 사본”이 됩니다.3 반면 Start-ThreadJob은 같은 프로세스 안의 스레드에서 동작하기 때문에 훨씬 가볍습니다.2

3. ForEach-Object -Parallel의 기본

가장 단순한 형태는 이렇습니다. $_는 현재 입력 객체이고, 바깥의 변수는 $using:을 붙여 참조합니다.1

$timeout = 2   # 호출자의 변수

$result = $computers | ForEach-Object -Parallel {
    $name = $_
    # 바깥의 변수는 $using: 을 붙이지 않으면 보이지 않는다
    $ok = Test-Connection -TargetName $name -Count 1 -TimeoutSeconds $using:timeout -Quiet

    # 집계용 공유 변수를 갱신하는 것이 아니라 "출력하는" 것이 기본형
    [pscustomobject]@{
        Computer = $name
        Alive    = $ok
        CheckedAt = Get-Date
    }
} -ThrottleLimit 20

$result | Where-Object { -not $_.Alive } | Format-Table

여기서 짚어야 할 설계 원칙이 하나 있습니다. 공유 변수를 갱신하는 것이 아니라, 각 스크립트 블록이 결과를 “출력”하고 호출자가 한데 모아 받는다. 이 형태로 해두면 스레드 안전성 문제는 애초에 발생하지 않습니다. 병렬 처리에서 문제가 되는 코드는 대개 공유 상태를 고쳐 쓰려고 합니다.

그래도 굳이 공유 컬렉션에 모으고 싶다면 스레드 안전한 타입을 사용합니다.1

# 공식이 예시하는 패턴: ConcurrentDictionary는 여러 스레드에서 안전하게 갱신할 수 있다
$safeDict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()

Get-Process | ForEach-Object -Parallel {
    $dict = $using:safeDict
    # TryAdd()는 boolean을 반환한다. 버리지 않으면 True/False가 출력에 섞인다
    $null = $dict.TryAdd($_.ProcessName, $_)
}

# 【위험】일반 Hashtable이나 List<T>를 $using:으로 전달해 갱신하는 것은 안전하지 않다
# $shared = @{}          # 동시 갱신으로 내부 구조가 깨져 예외나 값 소실이 발생한다

여기서 $using:의 의미를 정확히 해둡니다. 공식 문서는 ForEach-Object -Parallel에 대해 “Using: 한정자는 코맨드릿을 호출한 스레드에서 실행 중인 각 스크립트 블록의 스레드로 변수의 참조를 전달한다“고 명시하고 있습니다. 전달되는 것은 같은 프로세스 안의 같은 객체에 대한 참조입니다. 그렇기 때문에 변하지 않는 것을 읽기만 한다면 안전하고, 상태를 고쳐 쓴다면 스레드 안전한 타입이 필요하다는 이야기가 됩니다.1

그리고 이 “참조가 전달된다”는 성질은 같은 프로세스 안에서 동작하는 병렬화에 한정된 이야기입니다. Start-Job은 별도 프로세스, Invoke-Command -ComputerName은 별도 머신에서 동작하기 때문에 $using:으로 전달한 값은 직렬화되어 사본이 전달됩니다. 그쪽에서 고쳐 써도 호출자에게 반영되지 않으며, 돌아오는 객체도 메서드가 없는 역직렬화된 사본입니다.3 표기는 같은 $using:이라도 의미가 다르므로, ForEach-Object -Parallel의 감각 그대로 Start-Job에 가져가지 마세요.

4. 5가지 함정

(1) 호출자의 함수가 보이지 않는다

병렬 스크립트 블록은 별도의 런스페이스에서 실행되기 때문에, 호출자에서 정의한 함수는 그대로는 호출할 수 없습니다. 공통 처리는 모듈화해서 스크립트 블록 첫머리에서 임포트하는 것이 정석입니다.

$items | ForEach-Object -Parallel {
    Import-Module 'D:\Scripts\Modules\KsOps' -ErrorAction Stop   # 각 런스페이스에서 로드한다
    Convert-KsRecord -Input $_
} -ThrottleLimit 8

다만 각 런스페이스에서 모듈을 로드하는 비용이 병렬 수만큼 발생합니다. 무거운 모듈을 사용하는 처리에서는 병렬도를 높여도 한계에 부딪히는 주된 원인이 여기가 되는 경우가 있습니다. 모듈화의 작법은 “PowerShell의 인수 설계와 모듈화“에 정리했습니다.

(2) 런스페이스가 재사용되기 때문에 상태가 이어질 수 있다

PowerShell 7.0에서는 반복마다 새로운 런스페이스가 만들어졌지만, 7.1 이후로는 기본값으로 런스페이스 풀에서 재사용됩니다.1 성능상으로는 큰 개선이지만, 어떤 반복에서 설정한 환경 설정 변수나 현재 디렉터리 변경, 로드된 모듈의 상태가 같은 런스페이스를 사용하는 이후 반복에서 보일 수 있다는 뜻입니다. 반복 간의 완전한 독립이 필요하다면 -UseNewRunspace를 지정합니다(그만큼 느려집니다).1

(3) 출력도 에러도 순서가 보장되지 않는다

병렬 실행의 순서는 비결정적입니다. 에러 스트림에 쓰이는 순서도 무작위이며, 경고·상세·정보 스트림도 마찬가지입니다.1

1..5 | ForEach-Object -Parallel {
    if ($_ -eq 3) { throw "Terminating Error: $_" }   # 이 반복만 멈춘다
    "Output: $_"
}
# Output: 1 / Output: 4 / Output: 2 / Output: 5 (순서 무작위), Output: 3은 나오지 않는다

스크립트 블록 안의 종료 에러는 그 반복만 종료시키고 다른 병렬 실행은 계속됩니다. 에러는 FullyQualifiedErrorIdPSTaskException인 ErrorRecord로 에러 스트림에 쓰입니다. “한 건이라도 실패하면 전부 멈춘다”는 동작이 아니므로 전체 건의 성공·실패를 직접 집계해야 합니다.1

$results = $files | ForEach-Object -Parallel {
    # catch 블록 안에서는 $_ 가 ErrorRecord로 바뀌므로,
    # 입력 객체는 반드시 다른 변수에 대피시킨 뒤 사용한다
    $file = $_

    try {
        $hash = Get-FileHash -Path $file.FullName -Algorithm SHA256 -ErrorAction Stop
        [pscustomobject]@{ Path = $file.FullName; Hash = $hash.Hash; Error = $null }
    }
    catch {
        # 실패도 "출력"으로 반환하고 호출자에서 분류한다
        [pscustomobject]@{ Path = $file.FullName; Hash = $null; Error = $_.Exception.Message }
    }
} -ThrottleLimit 8

$failed = $results | Where-Object Error
if ($failed) { Write-Warning "$($failed.Count)건이 실패했습니다" }

(4) PipelineVariable을 사용할 수 없다

공통 매개변수인 -PipelineVariable$using:을 붙여도 병렬 시나리오에서는 지원되지 않습니다.1 순차 처리에서 이식할 때 걸리는 포인트입니다.

(5) 진행률 표시와 로그가 뒤섞인다

여러 스레드에서 동시에 Write-Progress나 자체 로그를 쓰면 행이 뒤섞이거나 파일이 충돌합니다. 로그는 스크립트 블록 안에서 직접 파일에 추가하지 말고 결과 객체로 반환해 호출자에서 한 곳에서 쓰는 것이 안전합니다. 출력 스트림 설계는 “PowerShell의 출력 스트림과 로그 설계“에서 다룹니다.

5. ThrottleLimit을 정하는 법

-ThrottleLimit은 동시에 실행되는 스크립트 블록의 수이며 기본값은 5입니다.1 7.1 이후로는 이 값이 런스페이스 풀의 크기 그 자체가 됩니다.1 동작을 그림으로 나타내면 다음과 같습니다.

한 건 끝날 때마다같은 런스페이스를 재사용런스페이스 풀 ThrottleLimit = 5런스페이스 1런스페이스 2런스페이스 3런스페이스 4런스페이스 5입력 200건대기열여유가 생길 때까지 대기출력순서 미정

입력 200건에 대해 런스페이스가 200개 만들어지는 것이 아니라, -ThrottleLimit 개수만큼 준비된 런스페이스를 재사용한다는 것이 요점입니다. 여기서 두 가지를 도출할 수 있습니다. 하나는 -ThrottleLimit을 올릴수록 메모리와 초기화 비용이 늘어난다는 것. 다른 하나는 재사용되기 때문에 앞선 반복의 상태가 남을 수 있다는 것(4장 (2))입니다.

정하는 기준은 처리의 성질로 나눕니다.

처리의 성질 기준 이유
네트워크 대기(연결 확인, API 호출) 코어 수보다 커도 괜찮음(20~50 정도부터 시도) CPU는 거의 놀고 있음. 제약은 상대편
디스크 I/O 대기 8~16 정도부터 시도 지나치게 올리면 랜덤 액세스가 늘어 역효과. SSD/HDD로 차이가 큼
CPU 계산(해시, 압축, 변환) 논리 코어 수 정도 그 이상은 컨텍스트 스위치 낭비
상대가 업무 서버·API 상대의 허용량이 상한 속도 제한이나 동시 접속 상한을 넘으면 장애의 가해자가 됨

마지막 행이 실무에서는 가장 중요합니다. 자신의 스크립트를 빠르게 하려다 업무 서버를 다운시키면 본말전도이므로, 사내 API나 파일 서버를 상대할 때는 이쪽의 병렬도를 “상대가 견딜 수 있는 범위”로 정합니다. API의 속도 제한 대처는 “PowerShell로 REST API와 연동하기“를 참조하세요.

-AsJob을 사용할 때의 주의점도 공식 문서에 명시되어 있습니다. ThrottleLimit은 ForEach-Object -Parallel 1회당 제한이며, 작업을 10개 만들면 “10 × ThrottleLimit”이 동시에 실행됩니다.1

한 가지 더, 같은 -ThrottleLimit이라는 이름이라도 명령에 따라 세는 대상이 다릅니다. 혼동하면 병렬도 산정을 잘못하게 되므로, 이 글에 등장하는 2가지를 나란히 정리해 둡니다.

명령 -ThrottleLimit이 세는 것 기본값
ForEach-Object -Parallel 동시에 실행되는 스크립트 블록의 수(= 런스페이스 풀의 크기) 51
Invoke-Command -ComputerName 동시에 접속하는 컴퓨터의 대수 324

Invoke-Command -ThrottleLimit 32는 “32대에 동시 접속한다”는 것이지 “한 대당 32병렬로 처리한다”는 것이 아닙니다. 둘을 조합하는(원격지에서 ForEach-Object -Parallel을 실행하는) 경우에는 대수 × 병렬도가 상대편의 총 부하가 된다는 점에도 주의하세요.

6. 병렬화하지 않는 편이 빠른 경우

공식 문서는 상당히 직설적으로 쓰고 있습니다 ── 새 런스페이스는 순차 처리에 비해 상당한 오버헤드가 있고, 병렬 스크립트가 사소한 처리라면 평소보다 훨씬 느려질 수 있다, 실제로 시험해서 효과가 있는 곳을 찾으라고 말입니다.1 공식 예제에도 “이것은 병렬화의 비효율적인 사용 예”라고 명시된 샘플이 있을 정도입니다.

판단 기준은 단순합니다.

  • 한 건당 처리 시간이 짧다(밀리초 단위) → 병렬화하지 않는다. 문자열 처리나 해시테이블 참조 같은 것은 순차 쪽이 빠름
  • 건수가 적다(수십 건) → 병렬화하지 않는다. 오버헤드가 상대적으로 큼
  • 한 건당 수백 밀리초 이상의 대기가 있다 → 병렬화의 가치가 있음
  • CPU를 오래 사용하는 계산이 있다 → 멀티코어에서 효과가 있음

그리고 반드시 측정하고 나서 결정하는 것입니다. Measure-Command로 순차 버전과 병렬 버전을 비교하면 수십 초 만에 답이 나옵니다. 측정 작법과 PowerShell 스크립트 고속화 전반은 “PowerShell 스크립트가 느릴 때 확인할 곳“에 정리했습니다.

# 순차와 병렬을 같은 입력으로 비교한다(몇 번 실행해서 안정된 값을 본다)
# 병렬 쪽은 별도의 런스페이스에서 동작하므로 호출자에서 정의한 함수는 보이지 않는다.
# 비교가 되지 않으므로 모듈을 로드하거나 처리를 직접 작성한다
$seq = Measure-Command {
    $items | ForEach-Object { Invoke-Work $_ }
}
$par = Measure-Command {
    $items | ForEach-Object -Parallel {
        Import-Module 'D:\Scripts\Modules\KsOps'   # ← 이게 없으면 명령을 찾지 못하는 오류가 난다
        Invoke-Work $_
    } -ThrottleLimit 16
}
'{0:N1}초 → {1:N1}초' -f $seq.TotalSeconds, $par.TotalSeconds

7. Start-ThreadJob과 Start-Job의 구분 사용

ForEach-Object -Parallel은 “같은 처리를 다수의 입력에 적용한다”는 형태에 맞지만, 성질이 다른 처리를 동시에 실행해서 나중에 회수하고 싶을 때는 작업(job)이 적합합니다.

# 서로 다른 처리를 동시에 실행하고 한꺼번에 기다린다(스레드 작업 = 경량)
# 스레드 작업도 별도의 런스페이스에서 동작하므로 호출자의 함수는 보이지 않는다.
# 각 작업의 첫머리에서 모듈을 로드한다(또는 자기 완결형 스크립트 블록으로 만든다)
$jobs = @(
    Start-ThreadJob -Name 'AD사용자 실사' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsAdUserReport
    }
    Start-ThreadJob -Name '파일 서버 용량' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsShareUsage
    }
    Start-ThreadJob -Name '라이선스 집계' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsLicenseSummary
    }
)

# 완료를 기다렸다가 결과를 회수한다. 에러는 -ErrorVariable로 반드시 받는다
$reports = $jobs | Receive-Job -Wait -ErrorAction SilentlyContinue -ErrorVariable jobErrors

# 상태가 Failed가 되는 것은 스크립트 블록이 "종료 에러"로 떨어졌을 때뿐이다.
# Write-Error 같은 비종료 에러로 끝난 작업은 Completed 상태 그대로이므로,
# 상태만 보고 있으면 "에러가 났는데 성공으로 취급"되는 일이 생긴다
$failed = @($jobs | Where-Object { $_.State -eq 'Failed' })
$jobs | Remove-Job -Force

if ($failed.Count -gt 0 -or @($jobErrors).Count -gt 0) {
    throw "병렬 실행에서 에러가 발생했습니다: $(($jobErrors | ForEach-Object { $_.Exception.Message }) -join ' / ')"
}

여기서 -AutoRemoveJob을 사용하지 않는 이유는 회수 직후에 작업이 사라져 버려서 어느 것이 실패했는지 확인할 수 없게 되기 때문입니다. Receive-Job의 에러는 기본값이 비종료 에러이므로, 아무것도 쓰지 않으면 “일부가 실패했는데 성공한 만큼만 돌아와서 완료된 것처럼 보인다”는 가장 곤란한 형태가 됩니다. 실사나 보고서 자동화에서는 이 누락이 숫자 오류로 조용히 남습니다.

Start-Job(프로세스 분리형)을 선택하는 것은 다음과 같은 경우입니다.3

  • 호출자 프로세스를 끌어들이고 싶지 않은 처리(크래시할 수 있는 네이티브 DLL을 호출하는 등)
  • 별도의 프로세스 환경이 필요한 처리(다른 문화권 설정이나 환경 변수로 동작시키고 싶은 경우)
  • 32bit / 64bit 등 실행 환경 자체를 나누고 싶은 처리

반대로 이런 경우에 해당하지 않는다면, 오버헤드와 직렬화 제약만큼 Start-Job이 불리합니다. 역직렬화된 객체가 메서드를 가지지 않는다는 점도 이후 처리에서 영향을 줍니다.3

8. Windows PowerShell 5.1만 있는 환경에서는

5.1에서는 -Parallel을 사용할 수 없습니다. 현실적인 선택지는 3가지입니다.

  1. Start-ThreadJob(PowerShell Gallery의 ThreadJob 모듈을 도입) ── 5.1에서도 경량 병렬을 사용할 수 있습니다. 사내 배포 작법은 “PowerShell 모듈의 사내 배포와 업데이트“를 참조2
  2. Invoke-Command -ComputerName ── 대상이 여러 대의 Windows라면 이것만으로 병렬이 됩니다(기본값 32대 동시)4
  3. PowerShell 7을 도입한다 ── 5.1과 7은 공존할 수 있으므로, 무거운 처리만 7로 돌리는 선택도 현실적입니다

1번의 도입은 다음과 같습니다. 순수한 5.1에서 그대로 Install-Module을 실행하면 실패하는 경우가 있으므로, 걸리기 쉬운 2가지 포인트도 함께 적어둡니다.

# 【1】 PowerShell Gallery는 TLS 1.2 이상을 요구한다.
#      Windows PowerShell 5.1의 기본값에서는 활성화되어 있지 않아,
#      "기본이 되는 연결이 닫혔습니다" 등으로 실패하는 경우가 있다.
#      이 세션에서만 TLS 1.2를 활성화한 뒤 실행한다
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# 【2】 도입. CurrentUser라면 관리자 권한이 필요 없다
Install-Module ThreadJob -Scope CurrentUser

# 【3】 확인
Import-Module ThreadJob
Get-Command -Module ThreadJob     # Start-ThreadJob이 나오면 도입에 성공한 것이다

걸리기 쉬운 것은 다음 2가지입니다.

  • TLS 1.2 ── PowerShell Gallery는 2020년 4월 이후 TLS 1.2 이상 연결을 전제로 합니다. 5.1에서는 위의 한 줄이 필요한 경우가 있습니다5
  • 리포지터리 신뢰와 실행 정책 ── PSGallery는 기본값으로 “신뢰되지 않은 리포지터리”로 취급되기 때문에 Install-Module에서 확인을 요청받습니다. 무인으로 도입한다면 -Force, 상시 사용한다면 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted로 한 번 신뢰 완료 상태로 만들어 둡니다. 또한 실행 정책이 Restricted(Windows 클라이언트의 기본값)이면 스크립트 로드에서 멈추므로 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 상당의 설정이 필요합니다. 영구적으로 Unrestricted로 하는 것은 피하세요6

3번이 결국 가장 저렴한 경우가 많아, 당사에서도 “병렬화를 위해 공들인 5.1 코드를 작성한다”보다 “그 처리만 7로 돌린다”를 권장하고 있습니다. 공존과 이행의 사고방식은 “Windows PowerShell 5.1과 PowerShell 7의 차이“에 정리해 두었습니다.

9. 실무의 정석(판단표)

하고 싶은 일 선택 보충
다수의 대상에 같은 처리(연결 확인, 해시 계산, API 호출) ForEach-Object -Parallel PowerShell 7 전용. 결과는 출력으로 반환하는 설계로1
여러 대의 Windows에서 같은 처리 Invoke-Command -ComputerName 기본값 32대 동시. 직접 병렬화는 불필요4
성질이 다른 처리를 동시 실행 Start-ThreadJob 경량. Receive-Job -Wait로 회수2
프로세스 분리가 필요 Start-Job 무거움. 결과는 역직렬화됨3
결과를 한 곳에 모으고 싶음 출력으로 반환 / ConcurrentDictionary 일반 Hashtable·List의 동시 갱신은 불가1
한 건이 가볍고 건수가 적음 병렬화하지 않음 오버헤드로 오히려 느려짐1
상대가 업무 서버·API ThrottleLimit을 상대 기준으로 정함 속도 제한·동시 접속 상한이 사실상의 상한
반복 간의 독립이 필수 -UseNewRunspace 재사용으로 인한 상태 이월을 피함(느려짐)1

10. 정리

  • ForEach-Object -Parallel은 PowerShell 7.0 이후의 기능으로, 각 스크립트 블록을 별도의 런스페이스에서 실행합니다. 호출자의 변수는 $using:, 함수는 모듈화해서 로드하는 것이 기본형입니다.
  • 공유 변수를 갱신하는 것이 아니라 각 반복이 결과를 출력해 호출자가 집약하는 설계로 하면, 스레드 안전성 문제는 대부분 피할 수 있습니다. 어쩔 수 없이 공유해야 한다면 Concurrent 계열의 타입을 사용합니다.
  • 기본 ThrottleLimit은 5입니다. 대기 시간이 지배적인 처리는 크게, CPU 처리는 코어 수 정도로, 상대가 있는 처리는 상대의 허용량이 상한입니다.
  • 출력·에러의 순서는 정해져 있지 않으며, 종료 에러는 그 반복만 멈춥니다. 전체 건의 성공·실패는 직접 집계하세요.
  • 병렬화는 만능이 아닙니다. 가벼운 처리나 적은 건수에서는 오버헤드로 느려집니다. 반드시 Measure-Command로 순차 버전과 비교한 뒤 채택하세요.
  • 5.1 환경에서는 Start-ThreadJob, 원격이라면 Invoke-Command, 그래도 부족하다면 PowerShell 7 도입을 검토하는 것이 현실적입니다.

샘플 코드 다운로드

이 글에서 다룬 코드는 그대로 실행할 수 있는 형태로 정리해서 배포하고 있습니다. ForEach-Object -Parallel / Start-ThreadJob의 실무용 정형이 들어 있습니다.

샘플 코드 다운로드(zip)

이 글의 샘플은 PowerShell 7.6에서 실제로 실행해 검증했습니다(Pester 8건). zip에 포함된 Invoke-SampleTests.ps1을 실행하면 여러분의 환경에서도 같은 검증을 재현할 수 있습니다.

# 구문 분석 + 정적 분석 + Pester 테스트
./Invoke-SampleTests.ps1

설정값(경로, 서버 이름, 테넌트 ID 등)은 예시입니다. 그대로 운영 환경에서 실행하지 말고 자사 환경에 맞게 바꿔서 사용하세요.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 시간이 걸리는 운영 스크립트의 고속화, 병렬 처리를 포함한 자동화 설계 리뷰, “병렬화했더니 결과가 이상해졌다” 같은 결함 조사를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, ForEach-Object. PowerShell 7.0에서 -Parallel 매개변수 집합이 추가된 것, 각 스크립트 블록이 새로운 런스페이스에서 실행되는 것, $using: 스코프 한정자에 의한 변수 전달, -ThrottleLimit의 기본값이 5이며 런스페이스 풀의 크기가 되는 것, 7.1 이후로는 런스페이스가 재사용되며 -UseNewRunspace로 새로 만들 수 있는 것, -AsJob과 -TimeoutSeconds의 동작, $using:으로 전달한 참조의 갱신에는 System.Collections.Concurrent 같은 스레드 안전한 타입이 필요한 것, 비종료 에러의 출력 순서가 정해져 있지 않은 것, 종료 에러가 개별 병렬 인스턴스만 종료시키며 FullyQualifiedErrorId가 PSTaskException이 되는 것, PipelineVariable이 병렬 시나리오에서 지원되지 않는 것, 새 런스페이스의 오버헤드가 크고 사소한 처리에서는 오히려 느려질 수 있는 것, -AsJob 사용 시 ThrottleLimit이 작업별 제한이라는 것에 대해.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

  2. Microsoft Learn, Start-ThreadJob. Start-ThreadJob이 별도 프로세스가 아니라 같은 프로세스 안의 독립된 스레드에서 스크립트 블록을 실행하는 것, Start-Job보다 경량인 것, 표준 작업 코맨드릿(Receive-Job 등)으로 다룰 수 있는 것, -ThrottleLimit에 의한 동시 실행 수 제어에 대해.  2 3 4 5

  3. Microsoft Learn, about_Jobs. 백그라운드 작업이 명령을 새로운 프로세스에서 비동기로 실행하는 것, Start-Job·Get-Job·Receive-Job·Wait-Job에 의한 작업 조작, 작업 결과가 직렬화를 거쳐 반환되는 것에 대해.  2 3 4 5 6 7

  4. Microsoft Learn, Invoke-Command. -ComputerName에 여러 컴퓨터를 지정해 같은 명령을 실행할 수 있는 것, -ThrottleLimit이 동시 접속 수를 제한하며 기본값이 32인 것, -AsJob에 의한 백그라운드 실행에 대해.  2 3 4 5 6

  5. Microsoft PowerShell Team Blog, PowerShell Gallery TLS Support. 2020년 4월 이후 PowerShell Gallery가 TLS 1.2를 기본으로 하며 클라이언트 쪽도 TLS 1.2 이상으로 접속해야 하는 것, Windows PowerShell 5.1에서는 [Net.ServicePointManager]::SecurityProtocol에 Tls12를 설정한 뒤 접속하는 회피책이 안내되어 있는 것에 대해. 

  6. Microsoft Learn, about_Execution_Policies. 실행 정책이 스크립트 로드 조건을 제어하는 구조인 것, 모든 범위에서 정의되지 않은 경우의 유효 정책이 Windows 클라이언트에서는 Restricted, Windows Server에서는 RemoteSigned가 되는 것, Restricted에서는 .ps1·.psm1을 포함한 스크립트 파일을 실행할 수 없는 것, -Scope를 지정해 현재 사용자나 프로세스에만 적용할 수 있는 것에 대해. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

ForEach-Object -Parallel은 Windows PowerShell 5.1에서도 사용할 수 있나요?
사용할 수 없습니다. -Parallel은 PowerShell 7.0에서 추가된 매개변수 집합으로, 5.1에는 애초에 존재하지 않습니다. 5.1에서 병렬화하려면 PowerShell Gallery에서 도입할 수 있는 ThreadJob 모듈의 Start-ThreadJob, 표준 Start-Job(프로세스 분리형이라 무거움), 또는 RunspacePool을 직접 구성하는 방법이 됩니다. 사내 스크립트의 고속화가 목적이라면 5.1에서 공들인 병렬 처리를 작성하기보다 PowerShell 7을 도입해서 -Parallel을 사용하는 편이 유지보수 측면에서도 유리합니다.
병렬화했는데 오히려 느려졌습니다. 왜 그런가요?
병렬 실행의 오버헤드가 처리 자체보다 크기 때문입니다. ForEach-Object -Parallel은 각 스크립트 블록을 별도의 런스페이스에서 실행하므로 순차 처리에 비해 상당한 오버헤드가 있으며, 공식 문서도 "병렬 스크립트가 사소한 처리라면 평소보다 훨씬 느려질 수 있다"고 명시하고 있습니다. 한 건당 처리가 몇 밀리초 만에 끝나거나, 건수가 수십 건밖에 안 되는 경우에는 병렬화하지 않는 편이 빠른 것이 보통입니다. 효과가 나는 것은 네트워크 대기나 파일 I/O 대기가 긴 처리, 또는 멀티코어에서 의미 있는 계산 처리입니다.
$using:으로 전달한 변수에 병렬 스크립트 블록 안에서 값을 쓸 수 있나요?
참조하고 있는 값을 읽는 것은 안전하지만, 쓰기는 대상이 스레드 안전한 타입이 아닌 이상 안전하지 않습니다. $using:은 호출한 스레드에서 각 스크립트 블록의 스레드로 변수의 참조를 전달하는 구조이며, 여러 스레드가 동시에 건드리기 때문에 일반 해시테이블이나 List를 갱신하면 깨집니다. 집계 결과를 모으고 싶다면 System.Collections.Concurrent 네임스페이스의 ConcurrentDictionary나 ConcurrentBag 같은 스레드 안전한 타입을 사용하거나, 애초에 각 스크립트 블록이 값을 출력하고 호출자가 받는 형태로 설계하세요.
ThrottleLimit은 얼마로 하는 것이 정답인가요?
처리의 성질에 따라 달라집니다. 기본값은 5입니다. 네트워크 대기나 파일 I/O 대기가 지배적인 처리(서버 연결 확인, API 호출 등)는 CPU 코어 수보다 큰 값이라도 효과가 있습니다. 반면 CPU를 다 쓰는 계산 처리에서는 코어 수 정도를 넘으면 경합으로 오히려 느려질 뿐입니다. 상대가 업무 서버나 API인 경우에는 이쪽의 사정만이 아니라 상대편의 동시 접속 상한이나 속도 제한도 상한이 됩니다. 먼저 기본값 5로 측정하고, 두 배로 늘려서 효과가 있는지 확인하는 진행 방식이 안전합니다.
병렬 스크립트 블록 안에서 직접 작성한 함수를 호출할 수 없습니다.
병렬 스크립트 블록은 호출자와는 별도의 런스페이스에서 실행되기 때문에, 호출자의 스코프에서 정의한 함수나 변수는 그대로는 보이지 않습니다. 대처법은 두 가지로, 공통 처리를 모듈(.psm1)로 만들어 스크립트 블록 첫머리에서 Import-Module하는 방법, 또는 함수 정의를 텍스트로 $using:을 통해 전달해 스크립트 블록 안에서 다시 정의하는 방법입니다. 유지보수 관점에서는 전자를 권장합니다. 다만 각 런스페이스에서 모듈을 로드하는 비용이 들기 때문에, 모듈이 무거운 경우 병렬도를 높여도 한계에 부딪힌다는 점에 주의하세요.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기