🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [32편] Process와 셸 명령 실행
일반 파이프로는 왜 안 될까
32편에서 Pipe로 자식 프로세스의 출력을 읽었습니다. 그런데 git이나 vim, ssh 같은 프로그램을 파이프로 실행해보면 이상한 일이 벌어집니다.
- 색깔 있는 출력이 갑자기 흑백으로 나옴 (컬러 코드가 사라짐)
- 줄 단위로 나와야 할 출력이 프로그램이 끝날 때까지 안 나옴 (버퍼링 방식이 바뀜)
- 비밀번호 입력 같은 프롬프트가 뜨지 않거나 응답을 못 받음
많은 프로그램은 자신의 출력이 터미널로 연결되어 있는지를 확인하고 동작을 바꿉니다. isatty()가 false면 “터미널이 아니다”라고 판단해서 컬러를 끄고, 줄 단위 버퍼링 대신 블록 버퍼링으로 전환합니다. 일반 Pipe는 터미널이 아니라 익명 파이프이기 때문에, 이런 프로그램들은 항상 “파이프로 연결됨” 모드로 동작합니다.
해결책은 진짜 터미널인 척하는 가짜 터미널, 즉 PTY(pseudo-terminal, 가상 터미널)를 만들어 주는 것입니다.
PTY의 구조
PTY는 master와 slave 한 쌍으로 이루어집니다.
[부모 프로세스] ⇄ [PTY master fd] ⇄ [커널] ⇄ [PTY slave] ⇄ [자식 프로세스(셸 등)]
- slave는 자식 프로세스 입장에서 완전한 터미널 디바이스입니다.
isatty()가 true를 반환하고, 터미널 크기(winsize)도 가지고 있습니다. - master는 부모 프로세스가 그 터미널의 “반대편 끝”을 잡고 있는 파일 디스크립터입니다. master에 쓰면 자식이 키보드 입력을 받은 것처럼 동작하고, 자식이 화면에 출력하면 그 내용이 master에서 읽힙니다.
Terminal.app, iTerm2 같은 터미널 에뮬레이터가 내부적으로 하는 일이 정확히 이것입니다. macOS 승인 프록시나 자동화 도구가 대화형 CLI 세션을 그대로 통과시키면서 내용을 관찰하고 싶을 때도 같은 구조를 씁니다.
Swift에서 PTY 열기
POSIX의 forkpty()는 fork와 PTY 생성을 한 번에 처리해 주는 편리한 함수지만, Swift/GCD 환경에서는 fork 직후 자식 쪽에서 안전하게 쓸 수 있는 함수가 제한적이라 다루기 까다롭습니다. 실무에서는 openpty()로 master/slave를 먼저 만들고, Process에 slave를 표준 입출력으로 연결하는 방식을 더 많이 씁니다.
import Foundation
func openPTY() -> (master: Int32, slavePath: String)? {
var masterFD: Int32 = 0
var slaveFD: Int32 = 0
var slaveNameBuffer = [Int8](repeating: 0, count: 128)
let result = openpty(&masterFD, &slaveFD, &slaveNameBuffer, nil, nil)
guard result == 0 else { return nil }
close(slaveFD) // Process에 경로로 다시 열어줄 것이므로 fd는 닫음
let slavePath = String(cString: slaveNameBuffer)
return (masterFD, slavePath)
}
openpty는 master fd, slave fd, 그리고 /dev/ttys### 형태의 slave 경로를 함께 돌려줍니다. slave는 파일 디스크립터로도, 경로로도 열 수 있는 일반적인 디바이스 파일이라는 점이 핵심입니다.
Process에 PTY 연결하기
func runInPTY(shellPath: String, arguments: [String]) throws -> FileHandle {
guard let (masterFD, slavePath) = openPTY() else {
throw NSError(domain: "PTYError", code: -1)
}
let slaveHandle = FileHandle(forUpdatingAtPath: slavePath)!
let process = Process()
process.executableURL = URL(fileURLWithPath: shellPath)
process.arguments = arguments
process.standardInput = slaveHandle
process.standardOutput = slaveHandle
process.standardError = slaveHandle
try process.run()
// 부모는 master만 들고 있는다 — 자식의 터미널을 관찰하는 창구
return FileHandle(fileDescriptor: masterFD, closeOnDealloc: true)
}
// 사용: master로 읽으면 셸이 실제 터미널에 출력하듯 나오는 내용을 그대로 받는다
let masterHandle = try runInPTY(shellPath: "/bin/zsh", arguments: ["-i"])
masterHandle.readabilityHandler = { handle in
let data = handle.availableData
if !data.isEmpty {
print(String(data: data, encoding: .utf8) ?? "")
}
}
// master에 쓰면 자식은 키보드 입력을 받은 것처럼 처리한다
masterHandle.write("ls -la\n".data(using: .utf8)!)
자식 프로세스 입장에서 표준 입출력은 모두 진짜 터미널(slave)입니다. 그래서 git, vim은 물론이고 색깔 있는 프롬프트를 그리는 대화형 셸도 정상적으로 동작합니다. 부모는 master만 읽고 쓰면서 그 세션을 통째로 가로챌 수 있습니다. 이 구조는 셸 스크립트가 표준입출력을 그대로 통과시키면서 내용을 기록(로그)하고 싶은 승인/감사 도구에서 널리 쓰입니다.
터미널 크기 맞추기 — winsize
PTY는 자신만의 “화면 크기”를 가지고 있습니다. 이 크기가 실제 터미널 창 크기와 다르면 vim이나 htop 같은 풀스크린 프로그램의 레이아웃이 깨집니다. ioctl로 크기를 맞춰줍니다.
import Darwin
func resizePTY(masterFD: Int32, columns: UInt16, rows: UInt16) {
var size = winsize(ws_row: rows, ws_col: columns, ws_xpixel: 0, ws_ypixel: 0)
_ = ioctl(masterFD, UInt(TIOCSWINSZ), &size)
}
TIOCSWINSZ는 “터미널 창 크기 설정” ioctl 명령입니다. 크기가 바뀔 때마다 이 호출을 해 주면, 자식 프로세스는 SIGWINCH 시그널을 받고 자신의 레이아웃을 다시 그립니다.
PTY 사용 시 흔한 실수
- slave fd를 부모가 계속 들고 있기 —
openpty직후 slave fd를 닫지 않으면, 자식이 종료돼도 master 쪽에서 EOF를 못 받아readabilityHandler가 영원히 대기합니다. - 텍스트 인코딩 가정 — 터미널 출력에는 ANSI 이스케이프 시퀀스(색상, 커서 이동)가 섞여 있습니다. 순수 텍스트로 취급하면 화면이 깨진 것처럼 보입니다. 로그로 남길 때는 이스케이프 시퀀스를 걸러내는 처리가 필요합니다.
- echo 중복 — PTY의 line discipline은 기본적으로 입력을 그대로 되돌려 보내는 echo를 켠 상태입니다. master에 쓴 입력이 다시 master 읽기에서 나타나는 것을 잊고 중복 처리하지 마세요.
핵심 요약
- 많은 CLI 프로그램은
isatty()로 터미널 여부를 확인해 컬러/버퍼링 동작을 바꾼다 - PTY는 master/slave 한 쌍 — slave는 자식에게 진짜 터미널, master는 부모가 그 반대편을 관찰하는 창구
openpty()로 PTY를 만들고,Process의 표준 입출력을 slave 경로로 연결한다ioctl(TIOCSWINSZ)로 터미널 크기를 맞추면 풀스크린 프로그램이 정상 동작한다- slave fd를 부모가 붙들고 있으면 자식 종료 후에도 EOF를 못 받는 흔한 함정이 있다
다음 편에서는 LaunchAgent를 다룹니다. 앱을 로그인할 때마다, 혹은 백그라운드에서 계속 살아있는 서비스로 등록하는 방법을 알아봅니다.
🤖 Generated with Claude Code