PowerShell로 REST API와 연동하기 ── Invoke-RestMethod의 실무

· · PowerShell, REST API, Windows, 자동화, 업무 시스템, 연동, JSON, 운영 개선

“기간 시스템의 웹 API에서 수주 데이터를 가져와 사내 엑셀 장표에 반영한다”, “SaaS의 근태 API를 매일 아침 호출해 당일 출근 예정을 출력한다” ── PowerShell이 업무에서 사용되는 상황 중, 최근 몇 년 사이 뚜렷하게 늘어난 것이 REST API 연동입니다. 전용 도구를 살 정도는 아니지만, 수작업으로는 감당이 안 된다. 그 틈을 메우는 도구로서 Invoke-RestMethod는 매우 강력합니다.

한편 동작하는 지점까지는 쉬워도, 운영에 올리는 순간 갑자기 어려워지는 것도 API 연동의 특징입니다. 다국어(예: 한국어)가 깨진다, 에러 응답의 내용을 읽을 수 없다, 가끔 429로 실패한다, 프록시 환경에서 통하지 않는다, Windows PowerShell 5.1에서만 TLS로 거부당한다. 모두 “상대가 있는 시스템”이기에 생기는 문제입니다.

이 글에서는 사내에서 PowerShell로 API를 호출하는 정보시스템 담당자·개발자를 대상으로, 인증, JSON 송수신, 에러 처리, 재시도, 페이징, 그리고 5.1 특유의 함정까지를 실무에서 필요한 순서로 정리합니다.

전제 환경・검증 환경

항목 내용
대상 버전 Windows PowerShell 5.1과 PowerShell 7 양쪽을 대상으로 합니다. -SkipHttpErrorCheck -Authentication -MaximumRetryCount -NoProxy 등 PowerShell 6 이후에서만 사용할 수 있는 기능은 본문에서 그때그때 명시합니다(5.1 고유의 제약은 §8에 정리했습니다)1
샘플의 검증 환경 글 말미에서 배포하는 샘플 코드는 PowerShell 7.6에서 실행해 검증했습니다(Pester 21건). §8의 5.1 고유 서술은 5.1 사양에 근거한 정리입니다
예시에 사용하는 API https://api.example.co.jp/...는 가상의 엔드포인트입니다. 그대로는 동작하지 않습니다. 응답 JSON의 예시도 설명을 위해 둔 가상의 것입니다

1. 먼저 결론

  • JSON/XML API라면 Invoke-RestMethod. 응답을 자동으로 객체화합니다. 상태 코드나 헤더가 필요하다면 Invoke-WebRequest, 또는 -StatusCodeVariable / -ResponseHeadersVariable을 사용합니다.23
  • 인증은 헤더로 넘기는 것이 가장 범용적입니다. PowerShell 6 이후는 -Authentication Bearer -Token(SecureString)도 사용할 수 있습니다.2
  • 다국어 JSON은 UTF-8 바이트 배열로 보내는 것이 확실합니다. -ContentTypecharset=utf-8을 명시합니다.
  • ConvertTo-Json의 기본 깊이는 2입니다. 중첩이 깊은 객체는 -Depth를 지정하지 않으면 잘려 나갑니다.4
  • 4xx/5xx는 종료 에러가 됩니다. PowerShell 7 이후는 $_.ErrorDetails.Message로 본문을 읽을 수 있습니다. -SkipHttpErrorCheck로 예외화를 멈추는 선택지도 있습니다.2
  • 429는 Retry-After에 따라 기다립니다. -MaximumRetryCount를 지정하면 내장 기능이 자동으로 따릅니다. 다만 내장 재시도는 400~599(와 304) 전부가 대상이라, 401이나 404도 재전송됩니다. 코드별 처리나 지수 백오프가 필요할 때만 직접 구현합니다.2
  • POST 같은 비멱등 요청은 자동으로 재시도하지 마세요. 멱등이란 같은 요청을 몇 번 보내도 결과가 바뀌지 않는 성질로, GET PUT DELETE는 멱등, POST는 비멱등입니다. 통신 에러나 5xx여도 서버 쪽에서는 처리가 완료된 경우가 있어, 재전송은 이중 등록이 됩니다.
  • Link 헤더 방식의 페이징은 -FollowRelLink로 자동화할 수 있습니다. 커서 방식은 직접 루프를 작성합니다.2
  • Windows PowerShell 5.1에는 고유의 함정이 있습니다. -UseBasicParsing의 필요 여부, TLS 1.2의 명시적 활성화, 인코딩 처리의 세 가지입니다.1
  • -SkipCertificateCheck는 영구적인 운영에서 사용하지 않습니다. 사내 CA를 신뢰시키는 것이 올바른 대처입니다.

2. Invoke-RestMethod와 Invoke-WebRequest

먼저 차이를 짚어 보겠습니다.23

  Invoke-RestMethod Invoke-WebRequest
응답 처리 JSON/XML을 자동으로 객체화 WebResponseObject(원본 본문・헤더・코드)
주요 용도 REST API HTML 가져오기, 상태나 헤더 참조
상태 코드 -StatusCodeVariable로 획득(PS7+) .StatusCode
헤더 -ResponseHeadersVariable로 획득(PS6+) .Headers

API를 호출한다면 기본은 Invoke-RestMethod입니다. 헤더나 코드가 필요한 상황에서도, 전용 변수 매개변수로 얻을 수 있습니다.

$data = Invoke-RestMethod -Uri 'https://api.example.co.jp/v1/orders' `
        -Headers @{ Authorization = "Bearer $token" } `
        -StatusCodeVariable status -ResponseHeadersVariable headers -TimeoutSec 30

"HTTP $status / 남은 요청 수: $($headers['X-RateLimit-Remaining'])"
$data.items | Select-Object orderId, customerName, amount

3. 인증을 넘기는 방법

가장 범용적인 것은 헤더에 직접 쓰는 방법으로, 5.1에서도 7에서도 동일하게 동작합니다.

# (1) Bearer 토큰(가장 일반적)
$headers = @{ Authorization = "Bearer $accessToken"; Accept = 'application/json' }
Invoke-RestMethod -Uri $uri -Headers $headers

# (2) API 키(헤더 이름은 제공 측 사양을 따름)
$headers = @{ 'X-Api-Key' = $apiKey }

# (3) Basic 인증(PowerShell 6 이후는 -Authentication을 사용 가능)
Invoke-RestMethod -Uri $uri -Authentication Basic -Credential $cred

# (4) Bearer를 -Token으로 넘기기(PowerShell 6 이후. SecureString으로 다룰 수 있음)
Invoke-RestMethod -Uri $uri -Authentication Bearer -Token $secureToken

# (5) 클라이언트 인증서
Invoke-RestMethod -Uri $uri -Certificate $cert

-Authentication을 사용하는 경우, PowerShell은 HTTPS 이외에서의 사용을 기본값으로 거부합니다(-AllowUnencryptedAuthentication으로 회피할 수 있지만, 평문으로 자격 증명이 흘러가므로 사용해서는 안 됩니다).2

토큰이나 API 키를 스크립트에 직접 쓰지 않는 것은 대전제입니다. SecretManagement를 사용한 보관 방법은 「PowerShell에서 자격 증명을 안전하게 다루기」에 정리했습니다.

4. JSON 보내기 ── 다국어(한국어)와 -Depth의 함정

전송에서 확실히 걸리는 것이 문자 깨짐중첩 잘림입니다.

ConvertTo-Json-Depth기본값이 2로, 그보다 깊은 계층은 전개되지 않고 타입 이름 문자열 등으로 치환됩니다.4 중첩이 있는 요청 본문에서는 반드시 지정하세요.

$body = @{
    order = @{
        customer = @{ code = 'C001'; name = '주식회사 샘플' }   # 3계층째
        lines    = @( @{ item = 'A-100'; qty = 3 } )
    }
}

# 【NG】기본값 -Depth 2에서는 customer나 lines의 내용이 사라진다
$json = $body | ConvertTo-Json

# 【OK】충분한 깊이를 지정한다
$json = $body | ConvertTo-Json -Depth 10

문자 깨짐 대책은 UTF-8 바이트 배열로 만들어 보내는 것이 가장 확실합니다.

$json  = $body | ConvertTo-Json -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)

$res = Invoke-RestMethod -Uri $uri -Method Post `
       -Headers @{ Authorization = "Bearer $token" } `
       -ContentType 'application/json; charset=utf-8' `
       -Body $bytes -TimeoutSec 60

PowerShell 7에서는 문자열을 그대로 넘겨도 UTF-8로 전송되지만, 5.1과 공용하는 스크립트에서는 바이트 배열로 만들어 두면 환경 차이를 흡수할 수 있습니다. Windows 전반의 문자 코드 사정은 「Windows의 문자 코드와 개행 코드」를 참조하세요.

보내기 전과 받은 후를, 왕복으로 확인한다. 위의 $body에서 만들고 싶은 JSON은 다음 형태입니다.

{
  "order": {
    "customer": { "code": "C001", "name": "주식회사 샘플" },
    "lines": [ { "item": "A-100", "qty": 3 } ]
  }
}

-Depth 지정을 잊으면, 3계층째에 해당하는 customerlines의 내용이 전개되지 않아 이 형태가 되지 않습니다. 전송 전에 $json을 그대로 화면에 출력해, 계층이 남아 있는지를 눈으로 확인하는 것이 가장 빠른 확인 방법입니다(해시 테이블에서 만들기 때문에, 키의 나열 순서는 열거 순서에 따라 달라집니다. 순서를 고정하고 싶다면 [ordered]@{}를 사용합니다).

이 요청에 대해, API가 다음과 같이 응답한다고 합시다(가상의 예시입니다).

{
  "orderId": "2026-000123",
  "status": "accepted",
  "customer": { "code": "C001", "name": "주식회사 샘플" }
}

Invoke-RestMethod는 응답을 자동으로 객체화하므로, 수신 측은 다음과 같이 쓸 수 있습니다.

$res.orderId              # 2026-000123
$res.customer.name        # 주식회사 샘플 ── 여기가 깨져 있다면 수신 측 문제

수신 측 문자 깨짐은, $res.customer.name을 읽을 수 있는지로 구분합니다. 깨져 있는 경우에는, 응답의 Content-Typecharset이 올바르게 선언되어 있는지를 의심하세요(-ResponseHeadersVariable로 헤더를 가져올 수 있습니다). 송신 측과 수신 측 중 어느 쪽이 원인인지를, 이 왕복으로 구분할 수 있습니다.

5. 에러 처리 ── 본문을 읽지 못하면 조사할 수 없다

Invoke-RestMethod는 4xx/5xx 응답을 종료 에러로 취급합니다. 즉 try/catch로 붙잡을 수는 있지만, API가 반환한 에러 메시지 본문을 어떻게 읽는가가 문제가 됩니다.

try {
    $res = Invoke-RestMethod -Uri $uri -Method Post -Body $bytes `
           -ContentType 'application/json; charset=utf-8' -TimeoutSec 30
}
catch {
    $status = $_.Exception.Response.StatusCode      # 예: BadRequest / 400
    # PowerShell 7 이후는 응답 본문이 여기에 들어간다(API의 에러 메시지)
    $detail = $_.ErrorDetails.Message
    Write-Warning "API 실패 ($status): $detail"
    throw
}

확인 관점. $status에는 HTTP 상태가 열거값으로 들어가며(주석대로 BadRequest 같은 표시가 됩니다), $detail에는 API가 반환한 본문이 문자열 그대로 들어갑니다. JSON을 반환하는 API라면 $detail | ConvertFrom-Json으로 항목을 꺼낼 수 있으므로, 에러 코드나 필드 이름으로 분기할 수 있습니다. 반대로 $detail이 빈 채라면, API가 본문을 반환하지 않았거나 Windows PowerShell 5.1에서 실행하고 있는 것입니다(5.1에서는 응답 스트림을 직접 읽어야 합니다. §8).

“HTTP 400이 반환되었지만 왜 거부됐는지 모른다”는 조사로 시간을 낭비하지 않기 위해, 에러 본문은 반드시 로그에 남기는 설계로 만드세요.

상태 코드로 분기하고 싶은 처리에서는, -SkipHttpErrorCheck를 사용해 예외화를 멈추는 편이 더 자연스럽게 작성할 수 있습니다(PowerShell 7 이후).2

$res = Invoke-RestMethod -Uri $uri -SkipHttpErrorCheck -StatusCodeVariable code -TimeoutSec 30
switch ($code) {
    200     { $res.items }
    404     { Write-Warning '대상이 존재하지 않습니다'; @() }
    { $_ -ge 500 } { throw "서버 측 에러: $code" }
    default { throw "예상치 못한 응답: $code" }
}

6. 재시도 ── 429와 일시적 실패

PowerShell 6 이후의 Invoke-RestMethod에는 -MaximumRetryCount-RetryIntervalSec가 있어, 실패 시 재시도합니다. 게다가 429 응답에 Retry-After가 포함된 경우에는, 지정한 간격이 아니라 그 헤더의 값이 사용됩니다.2 즉 “속도 제한에 걸리면 지시대로 기다렸다가 재시도한다”만으로 충분하다면, 내장 기능으로 족합니다.

# 속도 제한 대응만이라면, 이것으로 충분한 경우가 많다
Invoke-RestMethod -Uri $uri -Headers $headers -MaximumRetryCount 4 -RetryIntervalSec 5

다만, 재시도 대상은 429만이 아닙니다. 문서는 “400~599(및 304)의 실패 코드를 받았을 때 재시도한다”라고 정하고 있습니다.2 즉 401(인증 에러), 403(권한 부족), 404(URL 오류)처럼 몇 번을 보내도 고쳐지지 않는 요청까지 재시도됩니다. 토큰 설정을 잘못한 채 무인 실행하면, 실패라고 판명되기까지 -MaximumRetryCount × -RetryIntervalSec초를 허비하고, API에는 같은 무효한 요청이 반복해서 도달합니다. 영구적 에러에서 즉시 멈추고 싶다면, 이후의 직접 구현이 필요합니다.

자체 재시도 함수가 필요한 것은, 다음과 같은 요구가 있는 경우입니다.

  • 상태 코드별로 처리를 바꾸고 싶다(400번대는 즉시 실패, 5xx만 재시도 등)
  • 지수 백오프로 하고 싶다(내장 기능은 지정 간격에서의 재시도)
  • 실패 시 API의 에러 본문을 로그에 남기고 싶다

아래는 그 형태입니다.

function Invoke-KsApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Uri,
        [string] $Method = 'Get',
        [object] $Body,
        [hashtable] $Headers = @{},
        [ValidateRange(1, 10)] [int] $MaxAttempts = 4,
        # Retry-After가 이보다 긴 대기를 지시해 오면, 기다리지 않고 중단한다
        [ValidateRange(1, 86400)] [int] $MaxWaitSeconds = 300,
        # API가 멱등 키를 지원하는 경우에만, POST 등의 재시도를 허용한다
        [string] $IdempotencyKey
    )

    # 재시도해도 되는 것은, 같은 요청을 두 번 받아도 결과가 바뀌지 않는 경우뿐이다.
    # POST/PATCH는 "서버 측에서는 성공했지만 응답이 도착하지 않았다"는 경우가 있어,
    # 단순히 재전송하면 이중 등록이 된다
    $idempotentMethods = 'Get', 'Head', 'Options', 'Put', 'Delete'
    $canRetry = ($Method -in $idempotentMethods) -or $IdempotencyKey
    if ($IdempotencyKey) { $Headers = $Headers + @{ 'Idempotency-Key' = $IdempotencyKey } }

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $params = @{
            Uri                     = $Uri
            Method                  = $Method
            Headers                 = $Headers
            TimeoutSec              = 60
            SkipHttpErrorCheck      = $true          # 코드로 분기하고 싶으므로 예외로 만들지 않는다
            StatusCodeVariable      = 'code'
            ResponseHeadersVariable = 'resHeaders'
        }
        # $Body의 값이 $false나 0, 빈 문자열이어도 본문으로 보낼 수 있도록,
        # 진위가 아니라 "인수가 넘어왔는지"로 판정한다
        if ($PSBoundParameters.ContainsKey('Body')) {
            # 파이프로 넘기면 빈 배열 @()이 "입력 0건"이 되어 $null이 반환되므로,
            # -InputObject로 열거시키지 않고 그대로 변환한다(@()는 []가 된다)
            $json = ConvertTo-Json -InputObject $Body -Depth 10
            $params.Body        = [System.Text.Encoding]::UTF8.GetBytes($json)
            $params.ContentType = 'application/json; charset=utf-8'
        }

        # -SkipHttpErrorCheck가 억제하는 것은 HTTP 에러 응답뿐이다.
        # 타임아웃・이름 확인 실패・연결 재설정・TLS 에러 등, 응답이
        # 돌아오지 않는 통신 에러는 예외로 튀므로, 여기서 붙잡아 재시도한다
        try {
            $code = $null
            $res  = Invoke-RestMethod @params
        }
        catch {
            # 비멱등 요청은, 응답이 도착하지 않았을 뿐 서버 측은 성공했을
            # 가능성이 있다. 자동 재전송하지 않고, 호출자에게 판단을 맡긴다
            if (-not $canRetry) {
                throw "통신 에러($Method 는 재시도하지 않습니다. 처리 완료 여부를 확인하세요): $($_.Exception.Message)"
            }
            if ($attempt -eq $MaxAttempts) { throw }
            $wait = [math]::Min([math]::Pow(2, $attempt), 60)
            Write-Warning "통신 에러: $($_.Exception.Message) ── $wait 초 후 재시도합니다 ($attempt/$MaxAttempts)"
            Start-Sleep -Seconds $wait
            continue
        }

        if ($code -lt 400) { return $res }                    # 성공

        # 재시도할 가치가 있는 것은 일시적 에러뿐이다. 허용 목록 방식으로 명시한다
        # (405나 415 같은 영구적 에러를 재시도하면, 시간을 낭비한 데다
        #  마지막에는 "재시도 상한"이라는 무관한 메시지만 남는다)
        $retryable = @(408, 429, 500, 502, 503, 504)
        if ($code -notin $retryable) {
            throw "API 에러 ($code): $($res | ConvertTo-Json -Compress -Depth 3)"
        }
        # 5xx는 "서버 측에서 처리된 후 실패했다"는 가능성이 있으므로,
        # 비멱등 요청에서는 재전송하지 않는다. 429도 마찬가지(처리 전에 거부됐다는 보장이 없다)
        if (-not $canRetry) {
            throw "API 에러 ($code). $Method 는 자동 재시도하지 않습니다: $($res | ConvertTo-Json -Compress -Depth 3)"
        }

        if ($attempt -eq $MaxAttempts) {
            throw "재시도 상한에 도달했습니다 ($code): $($res | ConvertTo-Json -Compress -Depth 3)"
        }

        # Retry-After는 "초 단위 숫자"뿐 아니라 HTTP 날짜 형식으로 반환되기도 한다.
        # 그대로 [int]로 캐스팅하면 예외가 되어, 재시도할 때마다 떨어진다
        $wait = $null
        $retryAfter = if ($resHeaders) { $resHeaders['Retry-After'] | Select-Object -First 1 }
        if ($retryAfter) {
            $seconds = 0
            $date    = [datetime]::MinValue
            if ([int]::TryParse($retryAfter, [ref] $seconds)) {
                $wait = $seconds
            }
            elseif ([datetime]::TryParse($retryAfter,
                        [cultureinfo]::InvariantCulture,
                        [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref] $date)) {
                # 소수점 이하는 반드시 올림한다. [int]는 반올림(단수가 정확히 0.5면 짝수 쪽)이므로,
                # 남은 0.4초를 0초로 반올림해 서버의 기한 전에 재전송해 버린다
                $remaining = ($date - [datetime]::UtcNow).TotalSeconds
                $wait = if ($remaining -gt 0) { [int][math]::Ceiling($remaining) } else { 0 }
            }
        }
        if ($null -eq $wait) {
            $wait = [math]::Min([math]::Pow(2, $attempt), 60)             # 지수 백오프(상한 60초)
        }
        elseif ($wait -gt $MaxWaitSeconds) {
            # 서버의 지시를 임의로 줄여서 빨리 재전송하면, 429를 반복하며
            # 상한에 도달할 뿐이다. 기다릴 수 없는 길이라면, 대기 시간을 붙여서 호출자에게 반환한다
            throw "속도 제한 중입니다. 서버가 지시한 대기 시간 $wait 초가 상한 $MaxWaitSeconds 초를 초과하므로 중단했습니다. 시간을 두고 다시 실행해 주세요 ($code)"
        }
        Write-Warning "HTTP $code ── $wait 초 후 재시도합니다 ($attempt/$MaxAttempts)"
        Start-Sleep -Seconds $wait
    }
}

재시도하고 있는지 확인한다. 이 함수가 재시도에 들어가면, 시도할 때마다 Write-Warning이 한 줄 출력됩니다(HTTP 429 ── 30 초 후 재시도합니다 (1/4) 형태입니다). 볼 것은 3가지로, 대기 초수가 서버의 지시대로인지, 시도 횟수가 -MaxAttempts 범위 안에 들어가는지, 401이나 404 같은 영구적 에러에서 재시도하지 않고 즉시 예외가 되는지입니다. 무인 실행에서는 화면이 남지 않으므로, 경고 스트림도 로그에 남기세요(「Write-Host를 그만두기 ── PowerShell의 출력 스트림과 로그 설계」).

멱등성(같은 요청을 몇 번 보내도 결과가 바뀌지 않는 성질)의 처리가 두 번째 포인트입니다. GET이나 PUT은 같은 요청을 두 번 받아도 결과가 바뀌지 않지만, POST는 다릅니다. 특히 “서버 측에서는 등록에 성공했지만, 응답이 반환되기 전에 통신이 끊겼다”는 경우, 단순히 재전송하면 이중 등록이 됩니다. 위 구현에서는 멱등한 메서드이거나, API가 멱등 키(Idempotency-Key)를 지원하는 경우에만 자동 재시도를 허용하고, 그 외에는 “처리 완료 여부를 확인하세요”라고 명시하며 멈춥니다.

Retry-AfterHTTP 날짜 형식으로 반환됐을 때는, 남은 시간을 올림하세요. [int]로의 캐스팅은 절사가 아니라 반올림(단수가 정확히 0.5면 짝수 쪽)이므로, 응답이 도착할 때까지의 시간을 뺀 나머지가 0.5초 미만이 되면 0이 됩니다. Start-Sleep -Seconds 0은 즉시 반환되므로, 서버가 지정한 시각보다 먼저 재전송하게 되어, 돌아오는 것은 다시 429입니다. 이를 시도 횟수만큼 반복하고 “재시도 상한”으로 끝납니다. 위 구현에서 [math]::Ceiling을 끼워 넣은 것은 이 때문입니다.

Retry-After를 줄이지 않는 것도 중요한 포인트입니다. 서버가 “30분 후에 오라”고 지시하는데도, 상한을 이유로 5분 만에 재전송하면, 돌아오는 것은 같은 429뿐입니다. 쓸데없는 요청으로 시도 횟수를 다 써버린 데다, 마지막에는 “재시도 상한”이라는 메시지만 남습니다. 위 구현에서는, 지시받은 대기 시간이 -MaxWaitSeconds를 초과하면 짧게 다시 기다리는 것이 아니라, 대기 시간을 붙여 즉시 실패시킵니다. 배치 처리라면, 이 예외를 받아 다음 실행으로 넘길지, 지시받은 만큼의 시간을 기다릴지를 호출자 쪽에서 선택할 수 있습니다.

또 하나의 포인트는, 재시도할 대상을 허용 목록으로 명시하고 있다는 점입니다. “영구적 에러만 열거해서 제외한다”는 작성 방식으로 하면, 거기에 빠뜨린 코드(405 Method Not Allowed, 415 Unsupported Media Type 등)가 일시적 에러로 취급되어, 고쳐질 리 없는 요청을 반복한 끝에 “재시도 상한”이라는 원인 불명의 메시지만 남게 됩니다. 일시적이라고 알려진 코드만 재시도하고, 그 외에는 API의 에러 내용과 함께 즉시 실패시키는 것이 정답입니다. 재시도 설계의 일반론은 「PowerShell의 에러 처리와 재실행 설계」를 참조하세요.

7. 페이징

API가 전체 건수를 한 번에 반환하는 경우는 거의 없습니다. 방식은 주로 2가지입니다.

(1) Link 헤더 방식(GitHub 등이 채택)은, -FollowRelLink로 자동으로 다음 페이지를 따라갈 수 있습니다.2

# 다음 페이지를 자동으로 따라간다(가져올 페이지 수 상한도 지정 가능)
$all = Invoke-RestMethod -Uri $uri -Headers $headers -FollowRelLink -MaximumFollowRelLink 20

(2) 커서/오프셋 방식은 직접 루프를 작성합니다. 다음 코드는 §6에서 정의한 Invoke-KsApi를 사용하고 있다는 점에 주의하세요. 이 절만 시험해 보고 싶다면, Invoke-KsApi -Uri $u -Headers $headersInvoke-RestMethod -Uri $u -Headers $headers로 바꿔도 동작합니다(재시도와 에러 본문 기록이 사라질 뿐입니다).

$items    = [System.Collections.Generic.List[object]]::new()
$cursor   = $null
$page     = 0
$maxPages = 100

do {
    $page++

    # 원래 URI에 이미 쿼리가 붙어 있는지에 따라 구분 문자가 달라진다.
    # 항상 ?를 붙이면 .../items?status=active?cursor=... 가 되어,
    # 서버 쪽에서는 커서가 status 값의 일부처럼 보이게 된다.
    # 커서 자체도 불투명한 문자열(+ & = # 등을 포함할 수 있음)이므로 반드시 인코딩한다
    $u = if ($cursor) {
        $sep = if ($uri.Contains('?')) { '&' } else { '?' }
        "$uri$sep" + "cursor=$([uri]::EscapeDataString($cursor))"
    }
    else { $uri }

    $res = Invoke-KsApi -Uri $u -Headers $headers
    $items.AddRange([object[]]$res.items)
    $cursor = $res.nextCursor

    # 중단은 반드시 알린다. 조용히 빠져나가면 "전체를 가져왔다"고 오해하게 된다
    if ($page -ge $maxPages -and $cursor) {
        Write-Warning "페이지 수 상한 ($maxPages)에 도달하여 중단했습니다. 누락된 데이터가 있을 수 있습니다"
        break
    }
} while ($cursor)

"가져온 건수: $($items.Count)"

$itemsList[T]를 사용하는 것은, +=에 의한 배열 재생성을 피하기 위해서입니다(「PowerShell 스크립트가 느릴 때 살펴볼 곳」).

확인은 마지막 한 줄로 합니다. 가져온 건수: ...에 나온 숫자를, API 쪽의 총 건수(많은 API는 응답에 total 같은 항목을 가지고 있습니다)나 관리 화면의 건수와 대조하세요. 맞지 않는 경우에는, 먼저 중단 경고가 나오지 않았는지를 봅니다. 경고도 없이 건수가 부족하다면, nextCursor의 항목 이름이 사양과 일치하는지를 의심합니다.

페이지 수 상한을 반드시 두는 것도 잊지 마세요. 서버가 같은 커서를 계속 반환하거나, nextCursor를 비우는 것을 잊어버리는 결함은 실제로 일어납니다. 상한이 없으면, 그때 스크립트는 멈추지 않고 요청을 계속 보냅니다. 그리고 중단했다는 것은 경고로 겉에 드러낼 필요가 있습니다. 조용히 break하면, 호출자는 불완전한 결과를 전체라고 여기고 처리해 버립니다.

8. Windows PowerShell 5.1 고유의 함정

5.1이 남아 있는 환경에서는, 다음 3가지를 먼저 의심합니다.1

(1) TLS 1.2가 활성화되어 있지 않다. 오래된 기본 설정 그대로면, TLS 1.2 이상만 받아들이는 API에 연결할 수 없어 “기본 연결이 닫혔습니다”라는 에러가 됩니다.

# Windows PowerShell 5.1에서의 주문(스크립트 첫머리)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

(2) -UseBasicParsing이 필요한 경우가 있다. 5.1의 Invoke-WebRequest는 기본값으로 Internet Explorer 엔진을 사용해 HTML을 파싱하므로, IE가 초기화되지 않은 계정(서비스 계정 등)에서는 실패합니다. PowerShell 6 이후에서는 이 의존성이 사라져, -UseBasicParsing을 지정해도 무시됩니다.1

(3) -SkipHttpErrorCheck-Authentication이 존재하지 않는다. 5.1에서는 에러 본문을 읽기 위해 응답 스트림을 직접 읽어야 합니다. API 연동을 본격적으로 하려면, PowerShell 7을 도입하는 것이 가장 저렴합니다(「Windows PowerShell 5.1과 PowerShell 7의 차이」).

9. 프록시와 인증서

사내에서 인터넷상의 API를 호출하는 경우, 프록시 통과가 첫 번째 관문이 됩니다. -Proxy를 지정하지 않는 경우에는, 인터넷 설정(인터넷 옵션)이나 환경 변수로 구성된 프록시가 사용됩니다.2 즉 “아무것도 지정하지 않았다”는 “프록시를 사용하지 않는다”가 아닙니다. 이 점이, 실행 계정에 따라 결과가 달라지는 원인입니다.

증상 원인으로 볼 곳 대처
407 Proxy Authentication Required 프록시가 인증을 요구하고 있다 -Proxy와 함께 -ProxyCredential이나 -ProxyUseDefaultCredentials를 지정한다2
로컬에서는 통하는데 야간 배치만 실패한다 실행 계정마다 다른 인터넷 설정을 가져오고 있다 스크립트 쪽에서 -Proxy를 명시한다. 실행 계정을 맞춰서 검증한다
사내 API까지 프록시로 빨려 들어간다 기본 프록시 구성이 사내 대상에도 적용되고 있다 -NoProxy로 명시적으로 우회한다(PowerShell 6 이후)2
프록시 자격 증명을 두는 곳 스크립트에 직접 작성 SecretManagement 보관소에서 꺼낸다
# 프록시를 명시하고, 로그온 사용자의 자격 증명으로 인증한다
Invoke-RestMethod -Uri $uri -Proxy 'http://proxy.example.co.jp:8080' -ProxyUseDefaultCredentials

# 인증이 필요한 프록시에, 전용 계정으로 통과시킨다.
# -ProxyCredential은 -Proxy와 세트로 사용한다. -ProxyUseDefaultCredentials와는 함께 사용할 수 없다
$proxyCred = Get-Secret -Name 'ProxyAccount'   # 보관소에서 꺼낸다
Invoke-RestMethod -Uri $uri -Proxy 'http://proxy.example.co.jp:8080' -ProxyCredential $proxyCred

# 사내용 API는 프록시를 통하지 않는다(PowerShell 6 이후)
Invoke-RestMethod -Uri 'https://api.internal.example.local/v1/ping' -NoProxy

-ProxyCredential-ProxyUseDefaultCredentials는, 둘 다 -Proxy 지정이 전제이며, 동시에는 사용할 수 없습니다.2 Windows PowerShell 5.1에는 -NoProxy가 없습니다. 5.1에서 사내 대상 통신만 프록시에서 제외하고 싶은 경우에는, 실행 계정의 인터넷 설정 쪽에 있는 예외 목록(프록시를 사용하지 않는 주소)으로 대처하게 됩니다.

작업 스케줄러의 서비스 계정으로 실행하면, 대화형 로그온 시와는 프록시 설정이 다른 경우가 있습니다. “로컬에서는 동작하는데 야간 배치만 실패한다”의 전형적인 패턴입니다. 실행 계정을 맞춰서 검증하세요(「작업 스케줄러 작업이 실행되지 않는다」). 프록시 자격 증명을 스크립트에 직접 쓰지 않는 것은, API 토큰과 마찬가지입니다(「PowerShell에서 자격 증명을 안전하게 다루기」).

인증서 에러에 대해 -SkipCertificateCheck를 사용하는 것은, 검증 환경의 일시적인 회피로 한정하세요. 영구적인 대처는, 사내 CA의 인증서를 신뢰할 수 있는 루트 인증 기관 저장소에 배치하고, 서버 인증서를 올바르게 발급하는 것입니다.

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

논점 선택지 판단 기준
코맨드릿 Invoke-RestMethod / Invoke-WebRequest JSON API는 전자. 헤더・코드는 전용 변수로 획득23
인증 헤더 직접 작성 / -Authentication 5.1과 공용이라면 헤더 방식. 값은 SecretManagement로 보관
JSON 전송 문자열 / UTF-8 바이트 배열 + charset 명시 환경 차이로 인한 문자 깨짐을 피할 수 있다
ConvertTo-Json 기본값 / -Depth 명시 기본값은 2. 중첩은 반드시 지정4
에러 try/catch만 / 본문도 로그에 남긴다 $_.ErrorDetails.Message(PS7). 원인 규명 가능 여부가 달라진다2
분기가 많음 catch / -SkipHttpErrorCheck + 코드 분기 상태별로 처리를 나눈다면 후자가 자연스럽다2
재시도 -MaximumRetryCount / 직접 구현 429의 Retry-After에는 내장 기능이 자동으로 따른다. 다만 400~599 전부를 재시도하므로, 영구적 에러에서 즉시 실패시키고 싶을 때는 직접 구현2
POST의 재시도 멱등 키가 있는 경우만 응답이 도착하지 않았을 뿐 등록은 성공한 경우가 있어, 단순한 재전송은 이중 등록이 된다
페이징 -FollowRelLink / 직접 루프 Link 헤더 방식이라면 전자2
5.1 환경 그대로 / TLS 1.2 명시 + PS7 도입 검토 연결 에러의 대부분은 TLS 설정이 원인1
인증서 에러 -SkipCertificateCheck / 사내 CA를 신뢰 영구적인 운영에서는 검증을 무효화하지 않는다

11. 정리

  • JSON API는 Invoke-RestMethod가 기본입니다. 헤더나 상태 코드는 -ResponseHeadersVariable / -StatusCodeVariable로 얻을 수 있습니다.
  • 다국어 JSON은 UTF-8 바이트 배열로 보내고, charset=utf-8을 명시합니다. ConvertTo-Json -Depth 지정 누락은 중첩 누락을 초래합니다.
  • 4xx/5xx는 종료 에러입니다. $_.ErrorDetails.Message로 본문을 읽고, 로그에 남기세요. 분기가 많다면 -SkipHttpErrorCheck가 다루기 쉬워집니다.
  • POST 같은 비멱등 요청을 자동으로 재시도하지 마세요. 통신 에러나 5xx는 “서버 측에서는 처리 완료”일 가능성이 있어, 재전송하면 이중 등록이 됩니다. 재시도하려면 멱등 키 구조가 필요합니다.
  • 429는 Retry-After에 따라 기다리는 것이 기본입니다. -MaximumRetryCount를 사용하면 이 추종은 내장 기능으로 이루어집니다. 다만 내장 기능은 400~599 전부를 재시도 대상으로 하므로, 401이나 404를 즉시 실패시키고 싶다면 직접 재시도 구현이 필요합니다. 코드별 처리나 지수 백오프가 필요할 때도 마찬가지로, 영구적 에러는 재시도하지 않고 즉시 실패시킵니다.
  • 페이징은 Link 헤더 방식이라면 -FollowRelLink, 커서 방식은 직접 루프. 집계에 +=를 사용하지 마세요.
  • 5.1 환경에서는 TLS 1.2 명시, IE 엔진 의존, 기능 부족의 3가지가 벽이 됩니다. API 연동을 계속한다면 PowerShell 7 도입이 가장 빠른 해결책입니다.

샘플 코드 다운로드

이 글에서 다룬 코드는, 그대로 실행할 수 있는 형태로 묶어 배포하고 있습니다. 재시도・멱등성・페이징을 구현한 API 호출과, 검증용 HTTP 서버가 들어 있습니다.

샘플 코드 다운로드(zip)

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

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

zip에는 검증용 HTTP 서버가 들어 있으므로, 외부 API를 호출하지 않고 로컬에서 왕복을 재현할 수 있습니다. §5(에러 본문)・§6(429에서의 재시도 경고)・§7(가져온 건수)에서 언급한 확인 관점을, 그대로 시험해 보세요. 실제 API를 향하기 전에 이 3가지를 확인해 두면, 운영 환경에서 “동작하지 않는 이유를 모르겠다”는 상태가 되기 어려워집니다.

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

관련 글

관련 상담 영역

합동회사 코무라소프트는 기간 시스템이나 SaaS의 API를 활용한 사내 연동의 설계・구현, 기존 수작업을 API 연동으로 대체하는 자동화, 통신 관련 결함 조사를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. 웹 관련 코맨드릿의 동작 차이, Windows PowerShell 5.1 고유의 제약, PowerShell 7에서 추가된 기능에 대해. 아울러 ServicePointManager.SecurityProtocol 속성에 의한 TLS 버전 지정에 대해.  2 3 4 5

  2. Microsoft Learn, Invoke-RestMethod. REST 엔드포인트로 요청을 보내고, 응답의 JSON/XML을 PowerShell 객체로 변환해 반환한다는 점, -Headers / -Body / -ContentType / -Method 지정, -Authentication(Basic / Bearer / OAuth)과 -Token, HTTPS 이외에서의 인증이 기본값으로 거부된다는 점, -SkipHttpErrorCheck에 의한 4xx/5xx의 비예외화, -StatusCodeVariable 및 -ResponseHeadersVariable에 의한 획득, -MaximumRetryCount / -RetryIntervalSec에 의한 재시도, -FollowRelLink / -MaximumFollowRelLink에 의한 Link 헤더 페이징, -Proxy / -ProxyCredential / -ProxyUseDefaultCredentials(모두 -Proxy 지정이 전제이며, -ProxyCredential과 -ProxyUseDefaultCredentials는 함께 사용할 수 없다는 점), PowerShell 6.0에서 추가된 -NoProxy(인터넷 설정이나 환경 변수로 구성된 프록시를 우회한다는 점), -SkipCertificateCheck, -TimeoutSec에 대해.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

  3. Microsoft Learn, Invoke-WebRequest. 응답을 WebResponseObject로 반환하며, StatusCode・Headers・Content에 접근할 수 있다는 점, Windows PowerShell 5.1에서는 기본값으로 Internet Explorer 엔진을 사용해 HTML을 파싱하며 -UseBasicParsing으로 회피할 수 있다는 점, PowerShell 6 이후에서는 IE에 대한 의존이 사라져 -UseBasicParsing이 무시된다는 점에 대해.  2 3

  4. Microsoft Learn, ConvertTo-Json. -Depth의 기본값이 2이며, 그보다 깊은 계층이 변환되지 않는다는 점, -Compress에 의한 공백 제거에 대해.  2 3

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

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

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

자주 묻는 질문

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

Invoke-RestMethod와 Invoke-WebRequest는 어떻게 구분해서 사용하나요?
API의 JSON이나 XML을 다룬다면 Invoke-RestMethod입니다. 응답 본문을 자동으로 파싱해 PowerShell 객체로 변환해 주므로, ConvertFrom-Json을 직접 호출할 필요가 없습니다. Invoke-WebRequest는 응답을 HtmlWebResponseObject로 반환하며, 상태 코드・헤더・원본 본문에 접근할 수 있습니다. 상태 코드나 헤더를 보고 싶은 경우, 혹은 HTML을 그대로 다루고 싶은 경우에는 Invoke-WebRequest를 선택합니다. 다만 PowerShell 6 이후의 Invoke-RestMethod에는 -ResponseHeadersVariable과 -StatusCodeVariable이 있으므로, 헤더나 코드만 필요하다면 Invoke-RestMethod로도 얻을 수 있습니다.
한국어(다국어)를 포함한 JSON을 보내면 상대 쪽에서 문자가 깨집니다.
본문의 바이트열과 Content-Type 선언이 서로 어긋나 있기 때문입니다. 확실한 방법은 ConvertTo-Json으로 만든 문자열을 UTF-8 바이트 배열로 변환해 -Body에 넘기고, -ContentType에 charset=utf-8을 명시하는 것입니다. Windows PowerShell 5.1에서는 문자열을 그대로 넘기면 기본 인코딩으로 전송되는 경우가 있어, 이 대책이 특히 효과적입니다. 수신 쪽에서 문자가 깨지는 경우도 마찬가지로, 응답의 charset이 올바르게 선언되어 있는지 의심해 보세요.
API가 404나 500을 반환했을 때, 응답 본문의 에러 메시지를 읽고 싶습니다.
PowerShell 7 이후라면 catch 블록에서 $_.ErrorDetails.Message를 보면 응답 본문이 들어 있습니다. 상태 코드는 $_.Exception.Response.StatusCode로 가져올 수 있습니다. 또한 -SkipHttpErrorCheck를 붙이면 4xx/5xx여도 예외로 만들지 않고 일반 응답으로 받을 수 있으므로, 상태 코드로 분기하고 싶은 처리에서는 다루기 쉬워집니다. Windows PowerShell 5.1에서는 응답 스트림을 직접 읽어야 하므로, 이 점에서도 7의 이용을 권장합니다.
API에서 429(속도 제한)가 반환됩니다. 어떻게 대처해야 하나요?
응답의 Retry-After 헤더가 나타내는 초만큼 기다린 뒤 재시도하는 것이 기본입니다. 헤더가 없는 경우에는 지수 백오프(2초, 4초, 8초…)로 간격을 넓힙니다. PowerShell 6 이후에는 -MaximumRetryCount와 -RetryIntervalSec가 있으며, 429 응답에 Retry-After가 포함된 경우에는 지정한 간격이 아니라 그 헤더의 값이 사용되므로, 속도 제한 대응만이라면 내장 기능으로 충분합니다. 상태 코드별로 처리를 바꾸고 싶은 경우나 지수 백오프가 필요한 경우에만 자체 재시도 함수를 사용하세요. 또한 POST 같은 비멱등 요청의 재시도는 이중 등록의 우려가 있으므로, 멱등 키 구조가 없는 한 피해야 합니다. 근본적으로는 호출 횟수 자체를 줄이는 것(필요한 항목만 가져오기, 일괄 조회 API 사용)이 더 확실합니다.
사내 API의 자체 서명 인증서 때문에 에러가 납니다. -SkipCertificateCheck를 사용해도 되나요?
영구적인 운영에서는 피해야 합니다. 인증서 검증을 중단한다는 것은 통신 상대가 진짜인지 확인하지 않는다는 뜻으로, 사내 네트워크라 하더라도 중간자 공격의 여지를 남깁니다. 올바른 대처는 사내 CA의 인증서를 실행 환경의 신뢰할 수 있는 루트 인증 기관 저장소에 배치하고, 서버 인증서를 올바르게 발급하는 것입니다. 검증 환경에서 일시적으로 사용하는 경우에도, 운영 스크립트에 섞여 들어가지 않도록 설정 파일이나 매개변수로 명시적으로 전환하는 형태로 만드세요.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기