PowerShell의 병렬 처리 ── ForEach-Object -Parallel과 Job의 구분

· 업데이트: · · PowerShell, Windows, 병렬 처리, 성능 개선, 자동화, 운영 개선, 스크립트, Job

수정 이력(6건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 맨 앞에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
병렬 처리의 전제가 되는 runspace 설명을 새로 두고, $using:가 변수 참조를 넘기는 것은 동일 프로세스 안의 병렬화에 한정된다는 점을 보완했습니다. 더불어 ThreadJob 모듈 도입 절차, runspace pool의 동작을 보여주는 그림, 두 종류의 -ThrottleLimit 차이 표를 추가했습니다.
본문의 관련 기사 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174973)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「PowerShell의 병렬 처리 ── ForEach-Object -Parallel과 Job의 구분」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/powershell-parallel-processing/

DOI(등록된 아카이브)
10.5281/zenodo.22174973
DOI(마지막 등록 버전)
10.5281/zenodo.22174974

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

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
  • 각 script block은 별도의 runspace(실행 환경)에서 동작합니다. 호출 측 변수·함수는 그대로는 보이지 않습니다. 변수는 $using: scope modifier로 넘깁니다.1
  • -ThrottleLimit의 기본값은 5입니다. PowerShell 7.1 이후에는 runspace pool이 재사용되며, ThrottleLimit가 그 pool 크기가 됩니다. 매번 새로 만들려면 -UseNewRunspace입니다.1
  • $using:으로 넘기는 것은 「참조」입니다. 읽기만 하면 안전하지만, 갱신하려면 System.Collections.Concurrent의 thread-safe 형식이 필요합니다. 일반 Hashtable이나 List의 동시 갱신은 깨집니다.1
  • 병렬화는 「반드시 빨라지는」 조치가 아닙니다. 공식도, 사소한 처리를 병렬화하면 보통보다 훨씬 느려질 수 있다고 명시합니다. 효과가 있는 것은 대기 시간이 긴 처리와 멀티코어에서 의미가 있는 계산 처리입니다.1
  • 출력도 오류도 순서는 정해져 있지 않습니다. 오류 스트림에 쓰는 순서도 무작위이며, script block 안의 terminating error는 그 iteration만 멈추고 PSTaskException으로 보고됩니다.1
  • -AsJob으로 Job으로 던질 수 있습니다. 다만 -ThrottleLimit는 Job 하나당 병렬 수이므로, Job을 여러 개 만들면 동시 실행 수는 곱셈이 됩니다.1
  • 가벼운 병렬이면 Start-ThreadJob, 격리가 필요하면 Start-Job입니다. Start-Job은 별도 프로세스에서 동작하므로 overhead가 크고, 결과는 serialize되어 메서드를 잃은 상태로 돌아옵니다.23
  • 여러 대의 Windows에 같은 처리를 배포하려면 Invoke-Command의 암묵적 병렬 실행이 가장 빠른 방법입니다. 이쪽은 기본으로 32대까지 동시 실행합니다.4

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 16건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

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

2.1 전제 지식 ── runspace란

이 글에서 반복해서 나오는 「runspace」를 먼저 짚어 둡니다. runspace란 PowerShell이 스크립트를 실행하기 위한 「실행 환경」 그 자체입니다. 변수, 함수, 읽어 들인 모듈, 현재 디렉터리와 같은 세션 상태 전체가 이 runspace에 속합니다.

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

무엇인가 이 글에서의 의미
프로세스 OS에서 본 실행 단위(pwsh.exe 하나) 한 프로세스 안에 여러 runspace를 둘 수 있다
스레드 실제로 코드를 돌리는 실행의 흐름 runspace는 스레드 위에서 동작한다
runspace PowerShell 상태(변수·함수·모듈)의 그릇 여기가 갈리면 변수도 함수도 공유되지 않는다

이미지로는 「한 프로세스 안에, 변수도 함수도 따로 가진 PowerShell 세션을 여러 개 세울 수 있다」에 가깝습니다. ForEach-Object -Parallel은 이 runspace를 여러 개 마련해 script block을 각각 다른 스레드에서 돌리는 메커니즘입니다.1

이 글의 골치는 거의 전부 여기서 나옵니다. 호출 측 변수나 함수는 호출 측 runspace에 속하므로 다른 runspace에서는 보이지 않습니다. 그래서 변수는 $using:으로 명시적으로 넘겨야 하고, 함수는 모듈화해 각 runspace에서 읽어야 합니다. 한편 프로세스는 같으므로 메모리상의 객체 실체는 공유될 수 있습니다. 이것이 뒤에서 다룰 스레드 안전성 문제로 돌아옵니다.

「보이지 않는데 공유는 된다」── 이 겉보기에 모순된 성질이, runspace를 이해하지 못한 채 병렬화했을 때 사고로 이어지는 원인입니다.

2.2 네 가지 수단

먼저 지도를 잡아 둡니다. PowerShell에서 「동시에 돌리는」 수단은 크게 네 가지입니다.

수단 실행 단위 사용 가능한 버전 맞는 용도
ForEach-Object -Parallel 스레드(runspace) PowerShell 7.0+1 컬렉션 각 요소에 같은 처리. 제1 후보
Start-ThreadJob 스레드(runspace) 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이 무거운 것은 background Job이 별도 프로세스로 시작되기 때문입니다. 프로세스 시작 비용에 더해 결과는 serialize되어 돌아오므로, 받은 객체는 메서드를 갖지 않는 「deserialize된 복사본」이 됩니다.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

여기서 잡아 둘 설계 원칙이 하나 있습니다. 공유 변수를 갱신하는 것이 아니라, 각 script block이 결과를 「출력」하고 호출 측에서 한꺼번에 받는다. 이 형태로 두면 스레드 안전성 문제는 애초에 발생하지 않습니다. 병렬 처리에서 문제가 되는 코드는 대개 공유 상태를 바꾸려 합니다.

굳이 공유 컬렉션에 모으려면 thread-safe 형식을 씁니다.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: modifier는 cmdlet을 호출한 스레드에서, 실행 중인 각 script block의 스레드로 변수 참조를 넘긴다」고 명시합니다. 넘어가는 것은 같은 프로세스 안의 같은 객체에 대한 참조입니다. 그래서 변하지 않는 것을 읽기만 하면 안전하고, 상태를 바꾸려면 thread-safe 형식이 필요하다는 이야기가 됩니다.1

그리고 이 「참조가 넘어간다」는 성질은, 같은 프로세스 안에서 동작하는 병렬화에 한정된 이야기입니다. Start-Job은 별도 프로세스, Invoke-Command -ComputerName은 별도 머신에서 동작하므로 $using:으로 넘긴 값은 serialize되어 복사본이 도착합니다. 그쪽에서 바꿔도 호출 측에는 반영되지 않으며, 돌아오는 객체도 메서드를 갖지 않는 deserialize된 복사본입니다.3 표기는 같은 $using:이어도 의미가 다르므로, ForEach-Object -Parallel의 감각 그대로 Start-Job에 가져가지 마세요.

4. 다섯 가지 함정

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

병렬 script block은 다른 runspace에서 실행되므로, 호출 측에서 정의한 함수는 그대로는 호출할 수 없습니다. 공통 처리는 모듈화해 script block 맨 앞에서 import하는 것이 정석입니다.

$items | ForEach-Object -Parallel {
    Import-Module 'D:\Scripts\Modules\KsOps' -ErrorAction Stop   # 각 runspace에서 읽는다
    Convert-KsRecord -Input $_
} -ThrottleLimit 8

다만 각 runspace에서 모듈을 읽는 비용이 병렬 수만큼 발생합니다. 무거운 모듈을 쓰는 처리에서는, 병렬도를 올려도 상한에 걸리는 주원인이 여기가 되기도 합니다. 모듈화 방법은 「PowerShell의 인수 설계와 모듈화」에 정리했습니다.

(2) runspace가 재사용되므로 상태가 남을 수 있다

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

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

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

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

script block 안의 terminating error는 그 iteration만 종료시키고 다른 병렬 실행은 계속됩니다. 오류는 FullyQualifiedErrorIdPSTaskException인 ErrorRecord로 오류 스트림에 쓰입니다. 「1건이라도 실패하면 전부 멈춘다」는 동작이 아니므로, 모든 건의 성패를 직접 집계해야 합니다.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나 자체 로그를 쓰면 행이 섞이거나 파일이 경합합니다. 로그는 script block 안에서 직접 파일에 이어 쓰지 말고, 결과 객체로 반환해 호출 측에서 한곳에서 쓰는 것이 안전합니다. 출력 스트림 설계는 「PowerShell의 출력 스트림과 로그 설계」에서 다룹니다.

5. ThrottleLimit 정하는 법

-ThrottleLimit는 동시에 도는 script block 수이며, 기본은 5입니다.1 7.1 이후 이 값이 runspace pool 크기 자체가 됩니다.1 동작을 그림으로 그리면 다음과 같습니다.

1건이 끝날 때마다같은 runspace를 재사용runspace pool ThrottleLimit = 5runspace 1runspace 2runspace 3runspace 4runspace 5입력 200건대기열빈자리가 날 때까지 기다림출력순서는 정해지지 않음

입력 200건에 대해 runspace가 200개 만들어지는 것이 아니라, -ThrottleLimit 개수만큼 마련된 runspace를 재사용한다가 요점입니다. 여기서 두 가지가 따라옵니다. 하나는 -ThrottleLimit를 올릴수록 메모리와 초기화 비용이 늘어난다는 것. 다른 하나는 재사용되므로 이전 iteration 상태가 남을 수 있다는 것(4장 (2))입니다.

정하는 기준은 처리 성격으로 나눕니다.

처리 성격 기준 이유
네트워크 대기(연결 확인, API 호출) 코어 수보다 커도 된다(20〜50 정도부터 시험) CPU는 거의 놀고 있다. 제약은 상대 측
디스크 I/O 대기 8〜16 정도부터 시험 너무 올리면 랜덤 액세스가 늘어 역효과. SSD/HDD 차이가 크다
CPU 계산(해시, 압축, 변환) 논리 코어 수 정도 그 이상은 컨텍스트 스위치 낭비
상대가 업무 서버·API 상대가 감당할 수 있는 양이 상한 rate limit이나 동시 연결 상한을 넘으면 장애의 가해자가 된다

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

-AsJob을 쓸 때의 주의도 공식에 명시되어 있습니다. ThrottleLimit는 ForEach-Object -Parallel 1회당 제한이며, Job을 10개 만들면 「10 × ThrottleLimit」가 동시에 돕니다.1

한 가지 더, 같은 -ThrottleLimit라는 이름이어도 명령마다 세는 것이 다릅니다. 혼동하면 병렬도 추정이 빗나가므로, 이 글에 나오는 두 가지를 나란히 둡니다.

명령 -ThrottleLimit가 세는 것 기본값
ForEach-Object -Parallel 동시에 도는 script block 수(= runspace pool 크기) 51
Invoke-Command -ComputerName 동시에 연결하는 컴퓨터 대수 324

Invoke-Command -ThrottleLimit 32는 「32대에 동시 연결한다」이지 「1대당 32병렬로 처리한다」가 아닙니다. 둘을 조합하는(원격에서 ForEach-Object -Parallel을 돌리는) 경우에는 대수 × 병렬도가 상대 측 총부하가 되는 점에도 주의하세요.

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

공식 문서는 한발 더 들어간 표현을 씁니다 ── 새 runspace는 순차 처리에 비해 상당한 overhead가 있고, 병렬 스크립트가 사소한 처리이면 보통보다 훨씬 느려질 수 있다, 실제로 시험해 효과가 있는 곳을 찾으라고.1 공식 예에도 「이것은 병렬화의 비효율적 사용 예」라고 명시된 샘플이 있을 정도입니다.

판단 기준은 단순합니다.

  • 1건당 처리 시간이 짧다(밀리초 수준) → 병렬화하지 않는다. 문자열 처리나 Hashtable 참조 부류는 순차가 더 빠르다
  • 건수가 적다(수십 건) → 병렬화하지 않는다. overhead가 상대적으로 크다
  • 1건당 수백 밀리초 이상의 대기가 있다 → 병렬화할 가치가 있다
  • CPU를 오래 쓰는 계산이 있다 → 멀티코어에서 효과가 난다

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

# 순차와 병렬을 같은 입력으로 비교한다(몇 번 실행해 안정된 값을 본다)
# 병렬 측은 다른 runspace에서 동작하므로, 호출 측에서 정의한 함수는 보이지 않는다.
# 비교가 되지 않으므로, 모듈을 읽거나 처리를 직접 쓴다
$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이 맞습니다.

# 서로 다른 처리를 동시에 돌리고, 한꺼번에 기다린다(thread Job = 가벼움)
# thread Job도 다른 runspace에서 동작하므로, 호출 측 함수는 보이지 않는다.
# 각 Job 맨 앞에서 모듈을 읽는다(또는 스스로 완결된 script block으로 만든다)
$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가 되는 것은, script block이 「terminating error」로 떨어진 때뿐이다.
# Write-Error 같은 non-terminating error로 끝난 Job은 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을 여기서 쓰지 않는 이유는, 회수 직후 Job이 사라져 어느 것이 실패했는지 확인할 수 없게 되기 때문입니다. Receive-Job의 오류는 기본으로 non-terminating error이므로, 아무것도 쓰지 않으면 「일부가 실패했는데 성공한 분만 돌아와서 완료된 것처럼 보인다」는, 가장 곤란한 형태가 됩니다. 재고 조사나 리포트 자동화에서는 이 누락이 숫자 오류로 조용히 남습니다.

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

  • 호출 측 프로세스를 끌어들이고 싶지 않은 처리(충돌할 수 있는 native DLL을 치는 등)
  • 다른 프로세스 환경이 필요한 처리(다른 culture 설정이나 환경 변수로 돌리고 싶을 때)
  • 32bit / 64bit처럼 실행 환경 자체를 나누고 싶은 처리

반대로 이에 해당하지 않으면, overhead와 serialize 제약만큼 Start-Job은 불리합니다. deserialize된 객체는 메서드를 갖지 않는다는 점도 이후 처리에서 영향을 줍니다.3

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

5.1에서 -Parallel은 쓸 수 없습니다. 현실적인 선택지는 세 가지입니다.

  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하면 실패하는 경우가 있으므로, 걸리기 쉬운 두 점도 함께 적습니다.

# 【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이 나오면 도입된 것이다

걸리기 쉬운 것은 다음 두 점입니다.

  • 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 무거움. 결과는 deserialize된다3
결과를 한곳에 모으고 싶다 출력으로 반환 / ConcurrentDictionary 일반 Hashtable·List의 동시 갱신은 불가1
1건이 가볍거나 건수가 적다 병렬화하지 않는다 overhead로 오히려 느려진다1
상대가 업무 서버·API ThrottleLimit를 상대 기준으로 정한다 rate limit·동시 연결 상한이 사실상의 상한
iteration 사이 독립이 필수 -UseNewRunspace 재사용으로 인한 상태 남김을 피한다(느려진다)1

10. 정리

  • ForEach-Object -Parallel은 PowerShell 7.0 이후 기능이며, 각 script block을 별도 runspace에서 실행합니다. 호출 측 변수는 $using:, 함수는 모듈화해 읽는 것이 기본 형태입니다.
  • 공유 변수를 갱신하는 것이 아니라, 각 iteration이 결과를 출력하고 호출 측에서 모으는 설계로 두면 스레드 안전성 문제는 거의 피할 수 있습니다. 굳이 공유하려면 Concurrent 계열 형식을 씁니다.
  • 기본 ThrottleLimit는 5입니다. 대기 시간이 지배적인 처리는 크게, CPU 처리는 코어 수 정도, 상대가 있는 처리는 상대가 감당할 수 있는 양이 상한입니다.
  • 출력·오류의 순서는 정해져 있지 않고, terminating error는 그 iteration만 멈춥니다. 모든 건의 성패는 직접 집계하세요.
  • 병렬화는 만능이 아닙니다. 가벼운 처리나 적은 건수에서는 overhead로 느려집니다. 반드시 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 parameter set이 추가된 것, 각 script block이 새 runspace에서 실행되는 것, $using: scope modifier로 변수를 넘기는 것, -ThrottleLimit 기본값이 5이며 runspace pool 크기가 되는 것, 7.1 이후 runspace가 재사용되고 -UseNewRunspace로 새로 만들 수 있는 것, -AsJob과 -TimeoutSeconds의 동작, $using:으로 넘긴 참조의 갱신에는 System.Collections.Concurrent 같은 thread-safe 형식이 필요한 것, non-terminating error의 출력 순서가 정해져 있지 않은 것, terminating error가 개별 병렬 인스턴스만 종료시키고 FullyQualifiedErrorId가 PSTaskException이 되는 것, PipelineVariable이 병렬 시나리오에서 미지원인 것, 새 runspace의 overhead가 커서 사소한 처리에서는 오히려 느려질 수 있는 것, -AsJob 사용 시 ThrottleLimit가 Job별 제한인 것에 대해.  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이 별도 프로세스가 아니라 동일 프로세스 안의 독립 스레드에서 script block을 실행하며 Start-Job보다 가벼운 것, 기본 Job cmdlet(Receive-Job 등)으로 다룰 수 있는 것, -ThrottleLimit로 동시 실행 수를 제어하는 것에 대해.  2 3 4 5

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

  4. Microsoft Learn, Invoke-Command. -ComputerName에 여러 컴퓨터를 지정해 같은 명령을 실행할 수 있는 것, -ThrottleLimit가 동시 연결 수를 제한하고 기본값이 32인 것, -AsJob에 의한 background 실행에 대해.  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. 실행 정책이 스크립트 로드 조건을 제어하는 메커니즘인 것, 모든 scope가 미정의일 때 실효 정책이 Windows 클라이언트에서는 Restricted, Windows Server에서는 RemoteSigned가 되는 것, Restricted에서는 .ps1·.psm1을 포함한 스크립트 파일을 실행할 수 없는 것, -Scope를 지정해 현재 사용자나 프로세스에만 적용할 수 있는 것에 대해. 

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

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

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

자주 묻는 질문

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

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

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기