BESTNET TECH BLOG
AI 에이전트에게 Windows 실기 작업을 맡기고 ― SSH 비대화화·문자 코드·T-SQL의 “이음매”에서 밟은 실전기
시리즈 본편에서는, Oracle 19c에서 SQL Server 2022로의 마이그레이션 전처리를, AI 코딩 에이전트(Claude Code)에 실데이터를 넘기지 않고 자동화하여, 마이그레이션 대상 실기에서의 적용·기능 검증까지 완료한 것을 보고했습니다.
본고는 그 이면입니다. AI 에이전트에게 「실기에서의 작업」을 맡기면, 발이 걸리는 것은 대개 AI의 영리함이 아니라, AI와 셸과 OS의 문자 코드와 도구의 “이음매”였습니다. SSH의 비대화화, Windows의 문자 깨짐, cmd의 따옴표, 코드로부터의 T-SQL 투입 ―― 어느 것이나 단독으로는 수수하지만, 자동화를 멈추기에는 충분합니다. 실전에서 밟은 함정 중, 본고에서는 기술 쪽 10가지를, 증상·원인·대처의 형태로 남깁니다(나머지 2가지는 AI의 안전 기구에 관한 거버넌스 이야기이므로, 번외편 그 2 「AI 가드레일 편」으로). 같은 종류의 자동화를 검토하고 있는 분의 몇 시간을 절약할 수 있다면 다행입니다.
본고의 명령 예시는, 접속 대상·인증 정보를 모두 플레이스홀더(
USER@HOST,-pw '****',C:\work)로 치환하고 있습니다.
1. 접속 편 ― 핸즈프리의 SSH를 성립시킨다 #
AI 에이전트에게 작업시키는 전제로서, 사람이 한 번도 비밀번호를 치지 않고 SSH 명령이 통할 필요가 있습니다. 여기서 곧바로 3가지.
① OpenSSH는 비밀번호를 비대화로 넘길 수 없다 #
- 증상:
ssh user@host "コマンド"이 비밀번호 입력 대기로 멈춘다. 스크립트에서 자동 실행할 수 없다. - 원인: OpenSSH 클라이언트는 보안상, 비밀번호를 단말(tty)에서만 읽는다. 표준 입력에 파이프해도 받지 않는다. Windows에는
sshpass도 표준으로는 없다. - 대처: PuTTY의
plink를 사용하여-pw로 비밀번호를 넘긴다.
plink -ssh -pw '****' USER@HOST "whoami"
보안적으로는, 운영 환경에서는 공개 키 인증(키 페어+
pageant나-i)을 제일로 검토해 주십시오.-pw의 명령줄 넘김은 프로세스 일람이나 셸 이력에 남을 수 있으므로, 부득이하게 사용하는 경우에도 일회용+작업 후 로테이션이 전제입니다.
② plink의 초회 호스트 키 프롬프트에서 행한다 #
- 증상:
echo y | plink ...로 해도 진행되지 않는다. 타임아웃될 때까지 무반응. - 원인: plink는 초회 접속 시의 키 캐시 확인 「Store key in cache? (y/n)」을 단말(tty)에서 읽습니다. AI 에이전트 실행처럼 tty가 없는 환경(파이프/프로세스 기동 경유)에서는, 파이프한
y를 받지 못해 행합니다. 대화 콘솔상에서는echo y | plink가 듣는 구성도 있지만, 자동 실행에서는 믿을 수 없습니다. - 대처: 먼저
ssh-keyscan으로 호스트 키를 취하고, SHA256 핑거프린트를-hostkey로 핀 고정해 버린다.-batch로 프롬프트 자체를 무효화.
# 1) ホスト鍵のフィンガープリントを取得
ssh-keyscan -T 6 HOST 2>/dev/null | ssh-keygen -lf -
# => 256 SHA256:<fingerprint> <host> (...) のように、鍵種ごとに1行ずつ出る
# 2) 取得値を -hostkey で固定し、-batch で非対話接続
# サーバが複数の鍵種(ED25519/RSA等)を返す環境では、ネゴシエートされる鍵に備え各 -hostkey を列挙する
plink -ssh -hostkey SHA256:<fingerprint> -batch -pw '****' USER@HOST "コマンド"
핀 고정은 보안상으로도 플러스입니다(중간자 공격에 대해, 상정한 키 이외를 튕긴다).
③ 장시간 명령은 「던지고 기다리는」 설계로 한다 #
- 증상: ISO 다운로드나 제품 인스톨 같은 장시간 처리를 동기 실행하면, 도구 측의 타임아웃에 걸려 끊긴다.
- 원인: SSH 세션을 쥔 채 10분 이상 블록하는 처리는, 에이전트의 실행 제한에 걸리기 쉽다.
- 대처: 장시간 처리는 백그라운드에서 기동하고, 완료를 센티널 파일이나 종료 통지로 줍는다. SSH 세션을 장시간 점유하지 않는다.
:: 非同期起動し、完了時に終了コードをセンチネルへ(同一 cmd /c 内なので & は逐次評価される)
start "" /b cmd /c "installer.exe /quiet & echo DONE=%errorlevel%> C:\work\done.txt"
:: 別コマンドで完了確認: if exist C:\work\done.txt type C:\work\done.txt
2. 문자 코드 편 ― CP932와 BOM의 2단 구성 #
일본어 Windows를 상대하면, 문자 깨짐은 「겉모습의 문제」가 아니라 처리를 망가뜨리는 버그가 됩니다.
④ 리모트 출력이 Shift-JIS로 깨진다 #
- 증상: 명령 결과가
�w�肳�ꂽ�p�X...같은 문자 깨짐으로 읽을 수 없다. - 원인: 일본어 Windows는 CP932(Shift-JIS)로 출력한다. UTF-8 전제의 단말에 넘어가면 깨진다.
- 대처: 리모트 측 명령의 선두에서
chcp 65001(UTF-8로 전환). PowerShell은 출력 인코딩도 명시.
chcp 65001>nul & 後続コマンド
[Console]::OutputEncoding = [Text.UTF8Encoding]::new()
⑤ PowerShell 5.1은 BOM 없는 .ps1을 CP932로 읽는다【가장 시간을 녹인 함정】 #
- 증상: 스크립트 중의 일본어 리터럴(
'すべて'등)이'鬟溷刀'같은 다른 것으로 깨지고,'~' 付近に不適切な構文があります로 SQL이나 처리가 떨어진다. - 원인: PowerShell 5.1은, BOM이 없는
.ps1을 시스템 ANSI 코드 페이지(일본어 환경이라면 CP932)로 해석한다. UTF-8로 저장한 스크립트의 일본어가 망가진다. ASCII만의 스크립트는 무사하므로, 발각이 늦어지는 것이 골치 아프다. - 대처: 일본어를 포함하는
.ps1은 UTF-8(BOM 첨부)로 저장한다. PS5.1은 BOM을 보고 UTF-8로 판정한다.
# UTF-8 BOM付きで書き出す(既存のBOM無しUTF-8を変換する例)
$txt = [IO.File]::ReadAllText($src, [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($dst, $txt, [Text.UTF8Encoding]::new($true)) # $true = BOM付き
이 2가지(④⑤)는 다른 계층의 문제(단말 출력 vs 스크립트 읽기)이지만, 증상이 같은 「문자 깨짐」이므로 혼동하기 쉽습니다. 출력은 chcp, 입력(스크립트)은 BOM, 이라고 분리해서 외우면 빠릅니다.
3. 실행 패턴 편 ― cmd 따옴표 지옥으로부터의 탈출 #
⑥ cmd 경유의 인라인 PowerShell이 | " ;로 망가진다 #
- 증상:
powershell -Command "Get-ChildItem | Select-Object Name"을 흘리면'Select-Object' は…認識されていません."의 중첩에서도 무너진다. - 원인: SSH→cmd→PowerShell로 다단으로 넘어가기 때문에,
|를 cmd가 파이프로서 먼저 해석해 버린다. 큰따옴표의 중첩도 cmd 단계에서 망가진다. 표준 입력에 흘려 넣는 방식(-Command -)도, 긴 스크립트라면 도중에 끊기는 경우가 있었다. - 대처: 인라인을 포기하고, 로컬에서
.ps1을 쓴다→pscp로 전송→powershell -NoProfile -File로 실행한다. 본 작업의 환경(Windows Server 2022 기본의RemoteSigned)에서는, 로컬의 미서명.ps1이 그대로 동작했습니다. 이것으로 따옴표 문제도 표준 입력 끊김도 대부분이 해소됩니다.
본 작업에서는 이 패턴이 안정되었습니다. 「복잡한 리모트 처리는 인라인으로 애쓰지 말고, 파일로 만들어 보내서
-File로 두드린다」. 실행 정책을Bypass로 약화시킬 필요도 없습니다(그 조작은 오히려 안전 기구에 막혔습니다 ―― 번외편 그 2 참조).다만 전제에 주의가 필요합니다.
RemoteSigned로 전송 스크립트가 동작할지는, 조직의 그룹 정책이나 서명 정책(AllSigned/Constrained Language Mode/AppLocker・WDAC 등)에 따라 달라집니다. 또한 「pscp 전송 파일에 MOTW(Mark of the Web)가 붙지 않는다=서명 가드가 듣지 않는다」는 것은 이점이 아니라 트레이드오프이며, 제3자 유래의 스크립트에서는 오히려 리스크입니다. 사내에서 내용을 파악한 도구이므로 허용했다, 는 전제를 잊지 마십시오.
pscp -hostkey SHA256:xxxx -batch -pw '****' .\task.ps1 USER@HOST:C:/work/task.ps1
plink -ssh -hostkey SHA256:xxxx -batch -pw '****' USER@HOST "powershell -NoProfile -File C:\work\task.ps1"
4. SQL 투입 편 ― 코드에서 T-SQL을 흘릴 때의 함정 #
스키마 DDL을, sqlcmd를 거치지 않고 System.Data.SqlClient(.NET 표준의 SQL Server 접속 라이브러리. sqlcmd나 SSMS를 사용하지 않고 앱에서 직접 DDL/DML을 발행할 수 있는 반면, sqlcmd 전용 기능=GO 구분 등은 갖지 않는다)로, 통합 인증으로 직접 흘렸을 때의 3연발입니다.
⑦ GO 분할이, 코멘트 내의 GO로 갈라진다 #
- 증상: DDL을 배치 실행하면
コメントの終了マーク '*/' がありません이나'=' 付近に不適切な構文으로 일부 배치가 떨어진다. - 원인:
System.Data.SqlClient는GO를 이해하지 못합니다(GO는 sqlcmd/SSMS의 배치 구분이며 T-SQL이 아니다). 직접 분할할 필요가 있지만, 소박하게^\s*GO\s*$로 가르면,/* … */코멘트 안에 인덴트로 쓰여진GO(예시 코드 등)까지 구분해 버려, 코멘트가 분단됩니다. - 대처: 가르는 것은 행두(열0)의
GO만으로 한다. 코멘트 내의GO가 인덴트되어 있으면(이 코드베이스는 그랬습니다) 이것으로 제외할 수 있습니다. 보다 엄밀하게는 「코멘트나 문자열 리터럴을 제외한 다음에GO분할한다」가 본래적입니다.
# 列0のGOのみで分割(インデントされたコメント内GOは無視)
$batches = [regex]::Split($script, '(?im)^GO[ \t]*;?[ \t]*\r?$')
⑧ 인덱스 뷰의 작성이 ARITHABORT로 실패한다 #
- 증상:
CREATE UNIQUE CLUSTERED INDEX(인덱스 뷰화)가…SET options have incorrect settings: 'ARITHABORT'로 실패. - 원인:
System.Data.SqlClient는 기본으로ARITHABORT OFF. 인덱스 뷰의 작성에는 복수의 SET 옵션이 ON일 필요가 있다. - 대처: 접속 직후에 필요한 SET를 투입하고 나서 DDL을 흘린다.
SET ANSI_NULLS ON; SET ANSI_PADDING ON; SET ANSI_WARNINGS ON;
SET ARITHABORT ON; SET CONCAT_NULL_YIELDS_NULL ON;
SET QUOTED_IDENTIFIER ON; SET NUMERIC_ROUNDABORT OFF;
⑨ 건수 취득에서 列名 'rows' が無効です #
- 증상: 테이블별 건수를
SELECT SUM(p.rows) FROM sys.dm_db_partition_stats p …로 취하려고 하면列名 'rows' が無効です(Invalid column name 'rows')로 떨어진다. - 원인:
sys.dm_db_partition_stats(DMV=동적 관리 뷰. 서버 내부 상태를 반환하는 시스템 뷰)의 행 수 열은row_count이며rows가 아닙니다.rows열을 갖는 것은sys.partitions쪽. 잘못 알고 있었습니다. - 대처: 건수 집계는
sys.partitions를 사용한다(이것이 정석).
SELECT t.name, ISNULL(SUM(p.rows), 0) AS rows
FROM sys.tables t
LEFT JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1)
WHERE t.is_ms_shipped = 0
GROUP BY t.name ORDER BY t.name;
5. 뒷정리 편 ― 수수하지만 자동화를 멈추는 함정 #
⑩ Get-ChildItem -Exclude가 아무것도 반환하지 않아, 뒷정리가 진행되지 않는다 #
- 증상:
Get-ChildItem 'C:\work' -Exclude 'keep.sql' | Remove-Item -Force를 실행해도 1파일도 지워지지 않는다(에러도 나지 않는다). - 원인: 디렉터리 지정의
-Exclude는, 경로 끝에\*를 붙이거나-Recurse를 병용하지 않으면 기대대로 열거되지 않고 빈 것을 반환하는 경우가 있다(PowerShell의 알려진 버릇).Remove-Item에 넘어가는 입력이 비어 있으므로, 아무 일도 일어나지 않는다. - 대처: 게으름 피우지 말고, 대상을 명시 열거하여
Remove-Item -LiteralPath -Force.
foreach ($f in 'a.exe','b.iso','c.ps1') {
Remove-Item -LiteralPath (Join-Path 'C:\work' $f) -Force -ErrorAction SilentlyContinue
}
정리 ― 함정은 「영리함」이 아니라 「이음매」에 나온다 #
늘어놓고 보면, AI 에이전트 자체가 틀린 곳은 거의 없습니다. 막힌 것은 거의 전부가 이음매였습니다.
- AI와 셸의 이음매(cmd의 따옴표 해석, 호스트 키 프롬프트의 입력 경로)
- 셸과 OS의 이음매(CP932, BOM)
- 코드와 도구의 이음매(SqlClient가
GO를 모른다, DMV의 열명, ARITHABORT의 기본값)
뒤집어 말하면, 이음매를 표준화할수록 자동화는 안정됩니다(물론 다른 이음매가 남는 경우는 있습니다). 본 작업에서의 실용해는 다음 3가지로 집약되었습니다.
- 접속은
plink/pscp+ 호스트 키 핀 고정 +-batch로 핸즈프리화 - 복잡한 처리는 인라인으로 쓰지 말고,
.ps1을 보내서-File로 실행(본 작업의RemoteSigned환경에서는 성립. 조직의 정책에 따라 요확인) - 문자 코드는 「출력=chcp 65001/입력(스크립트)=UTF-8 BOM」으로 분리
또한, 이것들을 잡는 과정에서, AI 에이전트 측의 안전 기구가 AI 자신의 조작을 2회 차단하는 한 장면도 있었습니다. 실행 정책을 약화시키려 한 건과, 인증 정보를 파일에 쓰려고 한 건입니다. 이것은 「함정」이라기보다 거버넌스 이야기이므로, 번외편 그 2 「AI 가드레일 편」에서 다룹니다.