맥에서 윈도우 PC SSH 자동화, PowerShell 함정 10가지 정리
맥북을 메인으로 쓰면서 윈도우 PC 한 대를 보조로 굴리고 있습니다. 평소에는 거의 꺼두지만, 가끔 Feature Update를 돌리거나 윈도우에서만 검증되는 작업을 해야 할 때 SSH로 자동화를 걸어둡니다. 그런데 공식 문서를 그대로 따라가도 자꾸 어딘가에서 깨진다는 생각이 들었습니다. UAC가 발목을 잡거나, 잘 돌던 명령이 SSH를 거치자마자 빈 출력만 떨어뜨리는 식입니다.
하루 동안 누적된 함정을 한 문서에 모아두면 같은 자리에서 막힌 사람이 검색으로 찾아왔을 때 시간을 조금이라도 아낄 수 있겠다는 생각이 들었습니다. 그래서 직접 부딪혀가며 검증한 10가지를 정리합니다.
환경 전제
본문 전체는 다음 구성을 가정합니다. 다른 구성이라면 일부 함정은 해당되지 않을 수 있습니다.
- Mac(Apple Silicon) + Windows 10/11
- Windows OpenSSH Server 설치 및 자동 시작
- Tailscale 또는 LAN으로 두 머신 연결
- Windows 측 SSH 기본 셸은
cmd.exe(OpenSSH 기본값) - Mac
~/.ssh/config에 Host alias 등록, 공개키 인증 완료
이 글에서는 Mac 쪽 alias를 win-laptop으로, Windows 쪽 계정을 sshuser로 표기합니다.
함정 1: SSH 세션이 unelevated — UAC 토큰 분리
Administrators 그룹에 속한 계정으로 SSH 접속했는데도 액세스가 거부되었습니다 0x5가 떨어지는 경우가 있습니다. PSWindowsUpdate, COM API, 일부 레지스트리 쓰기에서 일관되게 발생합니다.
원인은 UAC가 같은 사용자에게 표준 토큰과 elevated 토큰을 분리해서 발급한다는 점입니다. SSH 로그온은 기본적으로 표준(unelevated) 토큰을 받기 때문에, 계정이 관리자여도 elevated 권한이 필요한 호출은 막힙니다.
# ❌ 그대로는 막힙니다
ssh win-laptop 'powershell -Command "Install-Module PSWindowsUpdate"'
윈도우 쪽에 다음 레지스트리 키를 한 번만 박아두면 이후 모든 SSH 세션이 elevated 토큰을 받습니다.
# ✅ 윈도우에서 한 번만 실행
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" `
-Name "LocalAccountTokenFilterPolicy" `
-PropertyType DWORD -Value 1 -Force
실제로 elevated인지는 다음 한 줄로 확인합니다. Elevated: True가 나오면 통과입니다.
ssh win-laptop 'powershell -Command "$p=New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent()); \"Elevated: \"+$p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)"'
함정 2: ReadConsoleOutput HostException
elevated 문제까지 해결했는데도 progress bar가 있는 cmdlet을 호출하면 ReadConsoleOutput HostException이 떨어집니다. SSH 비대화형 세션에는 진짜 콘솔 핸들이 없는데, PowerShell의 진행률 표시가 콘솔 핸들에 접근하려고 시도하기 때문입니다.
# ❌ progress bar가 있는 cmdlet은 그대로 던지면 깨집니다
ssh win-laptop 'powershell -Command "Get-WindowsUpdate"'
해결은 단순합니다. 명령 첫머리에 $ProgressPreference = "SilentlyContinue"를 박아 진행률 표시를 끄는 것입니다. SSH 자동화 명령의 사실상 디폴트로 두는 편이 편합니다.
# ✅ 진행률 표시를 끄고 호출
ssh win-laptop 'powershell -Command "$ProgressPreference=\"SilentlyContinue\"; Get-WindowsUpdate"'
함정 3: PSWindowsUpdate가 Feature Update를 못 찾음
Windows 10에서 11로 업그레이드하려고 Get-WindowsUpdate -MicrosoftUpdate를 돌렸는데 보안 업데이트만 나오고 Feature Update는 0건이 반환됐습니다. 한참을 검색 카테고리 옵션만 만지다가, PSWindowsUpdate 모듈이 Feature Update류를 빠뜨리는 잘 알려진 한계라는 사실을 확인했습니다.
이때는 모듈을 우회해서 COM API를 직접 부르면 됩니다. Windows Update Agent의 검색 결과를 그대로 받으니 Feature Update까지 같이 잡힙니다.
# ✅ COM API로 직접 검색
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and IsHidden=0")
$result.Updates | ForEach-Object { Write-Host $_.Title }
함정 4: Bash → ssh → cmd.exe → PowerShell, 4단 quote escape
같은 PowerShell 명령이 로컬에서는 잘 돌고, SSH로 넘기면 syntax error가 나거나 빈 출력만 떨어지는 경우가 있습니다. 특히 -join '', Select-String "...", here-string을 쓸 때 자주 만났습니다.
원인은 따옴표 해석 단계가 너무 많다는 점입니다. Mac Bash → SSH 클라이언트 → Windows sshd → cmd.exe → PowerShell까지 4단계를 거치면서 따옴표가 단계마다 한 번씩 소비되고, 그 과정에서 명령이 의도와 다르게 잘려나갑니다.
일정 길이가 넘어가면 inline을 고집하지 않고 .ps1 파일을 윈도우에 박아둔 뒤 호출하는 편이 훨씬 안전합니다. escape를 한 번만 통과시키면 되기 때문입니다.
# ✅ Mac에서 스크립트 작성
cat > /tmp/myscript.ps1 <<'EOF'
$items = Get-Process | Where-Object { $_.CPU -gt 10 }
$items | Format-Table Name, CPU
EOF
# ✅ SCP 전송
scp /tmp/myscript.ps1 win-laptop:/C:/Users/sshuser/myscript.ps1
# ✅ SSH로 실행 (escape 최소화)
ssh win-laptop 'powershell -ExecutionPolicy Bypass -File C:\Users\sshuser\myscript.ps1'
함정 5: SSH가 끊기면 작업도 함께 죽습니다
30분 이상 걸리는 업데이트 설치를 동기 SSH로 돌리다가, 노트북이 절전에 들어가거나 네트워크가 잠깐 끊기는 순간 작업도 같이 중단됐습니다. SSH 클라이언트 keepalive 한계(ServerAliveInterval=30 × CountMax=3 = 90초 무응답)를 넘어가면 세션이 죽고, 자식 프로세스도 함께 사라집니다.
이런 작업은 SSH로 직접 굴리지 않고, SCHTASKS로 SYSTEM 권한 작업을 등록한 뒤 SSH는 폴링 역할만 맡기는 편이 안전합니다.
# ✅ SYSTEM 권한 작업으로 등록
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-ExecutionPolicy Bypass -NoProfile -File C:\Users\sshuser\myjob.ps1"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(15)
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "MyJob" -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force
결과는 파일 로그로 받고, SSH는 그 로그를 주기적으로 읽어오는 정도로만 쓰는 식입니다.
함정 6: 한국어 그룹명을 인식하지 못함
Add-LocalGroupMember -Group "Remote Desktop Users" -Member sshuser가 한국어 빌드의 윈도우에서 실패했습니다. 그룹의 표시 이름이 OS 언어 빌드마다 달라서, 영문 하드코딩으로는 잡히지 않습니다.
다만 SID는 언어와 상관없이 고정이라, SID로 그룹을 가져오면 빌드 차이를 통째로 우회할 수 있습니다.
# ✅ SID로 그룹 조회 (언어 빌드 무관)
# Administrators = S-1-5-32-544
# Remote Desktop Users = S-1-5-32-555
$g = Get-LocalGroup -SID "S-1-5-32-555"
Add-LocalGroupMember -Group $g -Member "sshuser"
함정 7: 한국어 출력 인코딩이 깨집니다
로그를 받아보면 ���� 2:23:30 같이 깨진 문자열이 섞여 있는 경우가 있습니다. 대부분 표시만 깨지고 실제 동작에는 영향이 없지만, 디버그 정보를 못 읽으면 결국 같은 자리에서 또 막힌다는 점이 문제입니다. 원인은 PowerShell 콘솔 출력 인코딩이 SSH 비대화형 세션에서 의도와 다르게 잡힌다는 것입니다.
윈도우 측 PowerShell 안에서 출력 인코딩을 UTF-8로 강제하거나, Mac 쪽에서 LANG을 명시해 던지면 대부분 정리됩니다.
# ✅ PowerShell 안에서 출력 인코딩 강제
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# ✅ Mac 측에서 LANG을 명시
LANG=en_US.UTF-8 ssh win-laptop 'powershell -Command "Get-Date"'
함정 8: PSWindowsUpdate의 MaxDownloadSize가 부풀려져 있습니다
Feature Update가 "107 GB" 같은 비현실적인 크기로 표시될 때가 있습니다. 처음 봤을 때는 디스크가 부족하지 않을까 의심했는데, 알고 보니 PSWindowsUpdate가 표시하는 값은 bundle 안의 모든 component 크기를 합산한 값입니다. 실제 다운로드는 차분(diff) 기반이라 보통 3~4GB 선에서 끝납니다.
그 숫자는 그대로 무시하고, 실제 진행은 다운로드 로그나 빌드 번호 변화로 확인하는 편이 정확합니다.
# ✅ 현재 빌드 번호로 진행 판단
(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").CurrentBuild
함정 9: cmd.exe 직접 호출 vs powershell -Command 감싸기
SSH로 powercfg /change standby-timeout-ac 0을 던졌더니 EXIT 코드만 1로 떨어지고 출력은 비어 있었습니다. 같은 명령을 노트북 앞에서 cmd로 치면 멀쩡히 동작합니다. Windows OpenSSH의 기본 셸이 cmd.exe인데, powercfg나 ipconfig 같은 일부 시스템 명령이 SSH 비대화형 cmd.exe에서 환경변수·인코딩 처리 차이로 깨지는 경우가 있습니다.
# ❌ cmd.exe에 직접 던지면 깨지는 경우
ssh win-laptop 'powercfg /change standby-timeout-ac 0'
# ✅ powershell -Command로 감쌉니다
ssh win-laptop 'powershell -Command "powercfg /change standby-timeout-ac 0"'
이 경험 이후로는 단순한 룰 하나를 세워두고 따르고 있습니다. 모든 SSH 명령은 powershell -Command "..."로 감싸는 것을 디폴트로 두고, dir·type·set 같은 cmd 내장 명령만 inline으로 던집니다.
함정 10: 복잡한 PowerShell은 .ps1 파일로 분리
ssh win-laptop 'powershell -Command "줄1; 줄2; 줄3"' 형태로 명령을 길게 이어 붙이면 EXIT는 0인데 출력이 비어 있는 경우가 자주 발생합니다. 함정 4의 연장선입니다. 복잡할수록 escape가 어딘가에서 어긋날 확률이 높아지는 데다, 어디서 깨졌는지 진단하는 비용도 같이 올라갑니다.
그래서 inline으로 굴릴지 .ps1로 분리할지 미리 기준을 정해두면 시행착오가 줄어듭니다. 다음 표를 디폴트로 쓰고 있습니다.
| 조건 | 처리 |
|---|---|
| PowerShell 단일 cmdlet, 1줄 | inline OK |
;로 2~3개 명령 연결, 단순 + 따옴표 중첩 없음 |
inline OK |
| 큰따옴표 2쌍 이상 중첩 | .ps1 분리 |
| 줄바꿈 필요 | .ps1 분리 |
Select-String / 정규식 / here-string |
.ps1 분리 |
| 변수 치환 + 조건문/루프 | .ps1 분리 |
Write-Host에 한국어·특수문자가 다수 |
.ps1 분리 |
진단 팁 — EXIT 코드로 원인 분기하기
출력이 비어 있을 때 EXIT 코드만 보면 원인을 꽤 빠르게 좁힐 수 있다는 생각이 들었습니다. 직접 시행착오를 줄여준 분기 기준을 박스로 정리합니다.
EXIT=0 + 출력 없음 → 명령 자체가 깨져서 PowerShell이 빈 결과를 돌려준 경우입니다.
.ps1로 분리하거나 명령을 단순화하는 방향으로 갑니다.EXIT ≠ 0 + 출력 없음 →
cmd.exe단에서 명령을 인식하지 못한 경우입니다.powershell -Command "..."로 감싸 PowerShell이 받게 만들어야 합니다.
정리
돌이켜 생각해보면 이 10가지 함정은 따로 떨어진 버그라기보다 SSH 비대화형 세션의 결과 표면이 좁다는 한 가지 사실에서 파생된 문제들이었습니다. 콘솔도, 토큰도, 따옴표도, 인코딩도 GUI나 직접 입력에 비해 한 단씩 깎여 들어옵니다. 그 깎인 단을 어디서 메울지가 결국 자동화 안정성을 가른다는 생각이 들었습니다.
저는 다음 세 줄을 디폴트로 박아두고 시작합니다.
- 모든 SSH 명령은
powershell -Command "..."로 감싼다 - 명령 첫머리에
$ProgressPreference = "SilentlyContinue"를 둔다 - 두 줄 넘어가면 망설이지 말고
.ps1파일로 분리해scp로 올린다
실제로 가장 자주 만났던 함정은 1·4·9·10이었습니다. 비슷한 자리에서 막혀 있다면 이 네 개부터 점검해보시면 시간이 꽤 줄어드실 거라는 생각이 들었습니다.
관련 글
claude -p 를 LaunchAgent 에 붙일 때 만난 7가지 함정
대화 세션에서 잘 돌아가던 Claude Code 가 LaunchAgent 의 자식 프로세스로 들어가면 전혀 다른 얼굴이 됩니다. 인증·도구 억제·타임아웃 등 실측 기반 7가지 함정을 정리했습니다.
macOS에서 autossh로 SSH 터널 자동화하기: 운영 DB 안전하게 접근하는 방법
로컬 개발 환경에서 운영 MySQL에 접근할 때마다 SSH 터널을 수동으로 여는 건 번거롭습니다. autossh와 macOS LaunchAgent를 활용하면 부팅 시 자동으로 터널이 열리고, 끊어져도 재연결됩니다. 포트 충돌 방지와 보안 고려사항까지 정리했습니다.
대화 세션에서 돌리던 slash skill을 배치 자동화로 옮긴 이야기
매일 같은 slash skill을 손으로 돌리던 습관을 LaunchAgent 배치로 옮기면서 느낀 것은 기술 장벽보다 역할 재정의가 본질이라는 점이었습니다. 집행자에서 큐레이터로의 전환에 대한 기록입니다.