🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [31편] 길이-접두사 프레이밍과 IPC 프로토콜 설계
왜 앱에서 셸 명령이 필요한가
macOS 앱은 자기 자신만으로 모든 일을 하지 않습니다. Git 상태를 확인하거나, 다른 앱을 활성화하거나, 시스템 유틸리티를 호출해야 하는 순간이 옵니다.
git status로 저장소 상태 읽기osascript로 AppleScript 실행해서 다른 앱 포커스 주기launchctl로 백그라운드 서비스 등록/해제- 변환 도구(ffmpeg, sips 등)로 파일 처리
이런 외부 프로그램을 실행하는 창구가 Foundation의 Process 클래스입니다(예전 이름은 NSTask). Python의 subprocess, Node의 child_process와 같은 위치에 있습니다.
Process 클래스 기초
import Foundation
func runCommand(_ path: String, arguments: [String]) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = Pipe() // 필요 없으면 버림
try process.run()
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
// 사용
let output = try runCommand("/usr/bin/git", arguments: ["status", "--short"])
print(output)
executableURL에는 반드시 절대 경로를 넣습니다. "git"처럼 이름만 넣으면 실행 시점의 PATH 환경변수에 의존하게 되는데, 앱으로 실행될 때는 셸과 다른 PATH를 갖는 경우가 많아 예상과 다르게 실패하기 쉽습니다. which git으로 확인한 절대 경로를 쓰거나, /usr/bin/env를 경유하세요.
파이프 버퍼 데드락 — 흔한 함정
standardOutput과 standardError를 각각 별도의 Pipe로 지정한 뒤 한쪽만 읽으면 위험합니다. 파이프 버퍼는 보통 64KB 정도로 한정되어 있어서, 읽지 않은 쪽 파이프가 가득 차면 자식 프로세스가 그 파이프에 쓰다가 영원히 멈춰버립니다. 부모는 다른 쪽 파이프를 readDataToEndOfFile()로 읽으며 자식이 끝나길 기다리고 있으니, 서로 상대방을 기다리는 데드락이 발생합니다.
출력이 적다고 확신할 수 없다면 두 파이프 모두 readabilityHandler로 스트리밍하듯 비워주는 편이 안전합니다.
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
var stdoutData = Data()
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
stdoutPipe.fileHandleForReading.readabilityHandler = nil // EOF
} else {
stdoutData.append(chunk)
}
}
var stderrData = Data()
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
stderrPipe.fileHandleForReading.readabilityHandler = nil
} else {
stderrData.append(chunk)
}
}
비동기로 감싸기
terminationHandler는 프로세스가 끝났을 때 호출되는 클로저입니다. withCheckedThrowingContinuation으로 감싸면 async/await 코드에 자연스럽게 녹일 수 있습니다.
func runCommandAsync(_ path: String, arguments: [String]) async throws -> (output: String, exitCode: Int32) {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.terminationHandler = { finishedProcess in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (output, finishedProcess.terminationStatus))
}
do {
try process.run()
} catch {
continuation.resume(throwing: error)
}
}
}
// 사용
let (output, exitCode) = try await runCommandAsync("/usr/bin/git", arguments: ["status"])
guard exitCode == 0 else {
print("git 실패: \(output)")
return
}
terminationStatus는 프로세스의 종료 코드입니다. 0이 성공을 의미하는 것은 셸 관례와 같습니다. 출력이 비어있지 않다고 해서 성공한 것은 아니므로, 항상 종료 코드를 함께 확인하세요.
AppleScript 실행하기
macOS 자동화의 상당수는 결국 osascript를 통한 AppleScript 실행으로 귀결됩니다. 다른 앱을 활성화하거나 시스템 이벤트를 보낼 때 자주 씁니다.
func runAppleScript(_ script: String) throws {
_ = try runCommand("/usr/bin/osascript", arguments: ["-e", script])
}
// 특정 앱을 최전면으로 가져오기
try runAppleScript("""
tell application "Terminal"
activate
end tell
""")
AppleScript 문자열을 조립할 때 사용자 입력이 섞여 들어간다면 반드시 이스케이프하거나, 애초에 문자열 보간 대신 arguments 배열의 별도 항목으로 값을 넘기는 방법을 고민하세요. 다음 절의 인젝션 문제와 이어집니다.
커맨드 인젝션 피하기
가장 흔한 실수는 사용자 입력을 문자열로 이어붙여 셸에 넘기는 것입니다.
// ❌ 위험 — fileName에 세미콜론이나 백틱이 들어오면 임의 명령 실행 가능
let unsafeScript = "/bin/sh"
try runCommand(unsafeScript, arguments: ["-c", "cat \(fileName)"])
Process의 arguments는 배열입니다. 셸을 거치지 않고 각 인자를 그대로 자식 프로세스에 전달하므로, 아래처럼 쓰면 fileName에 어떤 특수문자가 들어와도 그 자체로 하나의 인자일 뿐 명령 조합에 관여하지 못합니다.
// ✅ 안전 — 셸 파싱을 거치지 않고 인자로 직접 전달
try runCommand("/bin/cat", arguments: [fileName])
부득이하게 /bin/sh -c로 셸 문법(파이프, 리다이렉션)이 필요하다면, 신뢰할 수 없는 입력은 절대 그 문자열에 보간하지 마세요.
체크리스트
- ✅ 절대 경로:
executableURL은 PATH에 의존하지 않는 고정 경로로 - ✅ 배열 인자: 셸 문자열 조합 대신
arguments배열 사용 - ✅ 양쪽 파이프 비우기: stdout/stderr 모두 읽거나 스트리밍해서 데드락 방지
- ✅ 종료 코드 확인:
terminationStatus로 성공 여부 판단 - ✅ 비동기 통합:
terminationHandler+ continuation으로 async/await와 연결
핵심 요약
Process(구NSTask)로 외부 프로그램 실행 — Pythonsubprocess와 같은 역할executableURL은 절대 경로,arguments는 배열로 — PATH 의존과 인젝션을 동시에 예방- stdout/stderr 파이프를 한쪽만 읽으면 버퍼가 가득 차 데드락 발생 가능
terminationHandler+withCheckedThrowingContinuation으로 async/await 통합osascript로 AppleScript를 실행해 다른 앱을 제어
다음 편에서는 PTY(가상 터미널)를 다룹니다. 일반 파이프로는 왜 대화형 프로그램을 제어할 수 없는지, 그리고 터미널 세션을 감시하는 도구들이 왜 PTY가 필요한지 살펴봅니다.
🤖 Generated with Claude Code