[Swift 입문] 32편 — Process와 셸 명령 실행

🤖 이 글은 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를 경유하세요.


파이프 버퍼 데드락 — 흔한 함정

standardOutputstandardError를 각각 별도의 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)"])

Processarguments는 배열입니다. 셸을 거치지 않고 각 인자를 그대로 자식 프로세스에 전달하므로, 아래처럼 쓰면 fileName에 어떤 특수문자가 들어와도 그 자체로 하나의 인자일 뿐 명령 조합에 관여하지 못합니다.

// ✅ 안전 — 셸 파싱을 거치지 않고 인자로 직접 전달
try runCommand("/bin/cat", arguments: [fileName])

부득이하게 /bin/sh -c로 셸 문법(파이프, 리다이렉션)이 필요하다면, 신뢰할 수 없는 입력은 절대 그 문자열에 보간하지 마세요.


체크리스트

  • 절대 경로: executableURL은 PATH에 의존하지 않는 고정 경로로
  • 배열 인자: 셸 문자열 조합 대신 arguments 배열 사용
  • 양쪽 파이프 비우기: stdout/stderr 모두 읽거나 스트리밍해서 데드락 방지
  • 종료 코드 확인: terminationStatus로 성공 여부 판단
  • 비동기 통합: terminationHandler + continuation으로 async/await와 연결

핵심 요약

  • Process(구 NSTask)로 외부 프로그램 실행 — Python subprocess와 같은 역할
  • executableURL은 절대 경로, arguments는 배열로 — PATH 의존과 인젝션을 동시에 예방
  • stdout/stderr 파이프를 한쪽만 읽으면 버퍼가 가득 차 데드락 발생 가능
  • terminationHandler + withCheckedThrowingContinuation으로 async/await 통합
  • osascript로 AppleScript를 실행해 다른 앱을 제어

다음 편에서는 PTY(가상 터미널)를 다룹니다. 일반 파이프로는 왜 대화형 프로그램을 제어할 수 없는지, 그리고 터미널 세션을 감시하는 도구들이 왜 PTY가 필요한지 살펴봅니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다