[Swift 입문] 30편 — Unix Domain Socket과 프로세스 간 통신

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [29편] TCP 소켓과 네트워크 통신

프로세스 간 통신(IPC)이란?

하나의 앱이 모든 일을 하는 경우는 드뭅니다. 메인 앱과 도우미 프로세스, 앱과 셸 스크립트, 또는 두 개의 독립 앱이 서로 데이터를 주고받아야 할 때가 있습니다. 이것을 IPC(Inter-Process Communication, 프로세스 간 통신)라고 합니다.

macOS에서 IPC를 구현하는 방법은 여러 가지입니다.

방법 특징 적합한 상황
TCP 소켓 네트워크를 통해 통신 원격 서버, 다른 기기
Unix Domain Socket 파일 시스템을 통해 통신 (로컬 전용) 같은 컴퓨터 내 프로세스
XPC Apple 전용 고수준 IPC 앱 확장, 특권 분리
Distributed Objects 구식, 잘 사용 안 함
표준 입출력(stdin/stdout) 부모↔자식 프로세스 간 파이프 셸 스크립트와 통신

Unix Domain Socket이란?

Unix Domain Socket(UDS)은 TCP 소켓과 같은 API를 사용하지만, 인터넷을 거치지 않고 파일 시스템 경로를 주소로 사용합니다.

TCP 소켓: 192.168.1.10:8080  (IP 주소 + 포트)
Unix Domain Socket: /tmp/myapp.sock  (파일 경로)

TCP 소켓 대비 장점

  • 더 빠름: 네트워크 스택을 거치지 않아 로컬 통신 속도가 TCP보다 빠릅니다
  • 파일 권한으로 보안: 소켓 파일의 읽기/쓰기 권한으로 접근 제어
  • 포트 충돌 없음: 포트 번호 대신 파일 경로를 사용하므로 충돌 위험이 없음

Docker, PostgreSQL, Redis 같은 서버 소프트웨어도 로컬 IPC에 UDS를 사용합니다.


Swift에서 Unix Domain Socket 서버

import Foundation
import Network

class UnixSocketServer {
    private var listener: NWListener?
    private let socketPath: String
    private let queue = DispatchQueue(label: "uds.server")

    init(socketPath: String) {
        self.socketPath = socketPath
    }

    func start() throws {
        // 기존 소켓 파일 제거 (재시작 시 충돌 방지)
        try? FileManager.default.removeItem(atPath: socketPath)

        // Unix Domain Socket 엔드포인트
        let endpoint = NWEndpoint.unix(path: socketPath)
        let parameters = NWParameters()
        parameters.defaultProtocolStack.transportProtocol =
            NWProtocolTCP.Options()

        // NWListener는 UDS도 지원
        listener = try NWListener(using: .tcp, on: .any)

        // 실제로는 POSIX API를 사용하는 것이 더 일반적
        // (아래 POSIX 방식 참고)
    }
}

Apple의 Network 프레임워크는 Unix Domain Socket을 완전히 지원하지 않아, macOS에서는 POSIX C API를 직접 사용하는 경우가 많습니다.

POSIX API로 UDS 서버

import Foundation

class PosixUnixSocketServer {
    private let socketPath: String
    private var serverFd: Int32 = -1
    private var running = false

    init(socketPath: String) {
        self.socketPath = socketPath
    }

    func start() {
        // 기존 소켓 파일 삭제
        unlink(socketPath)

        // 소켓 생성 (AF_UNIX = Unix Domain, SOCK_STREAM = TCP 유사)
        serverFd = socket(AF_UNIX, SOCK_STREAM, 0)
        guard serverFd >= 0 else {
            print("소켓 생성 실패")
            return
        }

        // 주소 구조체 설정
        var addr = sockaddr_un()
        addr.sun_family = sa_family_t(AF_UNIX)
        withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
            ptr.withMemoryRebound(to: CChar.self, capacity: 108) { cPtr in
                _ = socketPath.withCString { strncpy(cPtr, $0, 107) }
            }
        }

        // 바인드 (소켓을 경로에 연결)
        let bindResult = withUnsafePointer(to: &addr) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
                bind(serverFd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
            }
        }
        guard bindResult == 0 else { print("bind 실패"); return }

        // 리슨 (연결 대기)
        listen(serverFd, 5)
        print("서버 시작: \(socketPath)")

        running = true
        acceptLoop()
    }

    private func acceptLoop() {
        DispatchQueue.global().async { [weak self] in
            guard let self = self else { return }
            while self.running {
                let clientFd = accept(self.serverFd, nil, nil)
                if clientFd >= 0 {
                    self.handleClient(clientFd)
                }
            }
        }
    }

    private func handleClient(_ fd: Int32) {
        DispatchQueue.global().async {
            var buffer = [UInt8](repeating: 0, count: 4096)
            while true {
                let bytesRead = recv(fd, &buffer, buffer.count, 0)
                if bytesRead <= 0 { break }

                let data = Data(buffer[0..<bytesRead])
                if let text = String(data: data, encoding: .utf8) {
                    print("수신: \(text)")

                    // 에코 응답
                    data.withUnsafeBytes { ptr in
                        _ = send(fd, ptr.baseAddress!, data.count, 0)
                    }
                }
            }
            close(fd)
        }
    }

    func stop() {
        running = false
        close(serverFd)
        unlink(socketPath)
    }
}

Python 스크립트와 Swift 앱 연결

실제 앱에서 자주 쓰이는 패턴은 Swift 앱과 Python 스크립트가 Unix Domain Socket으로 대화하는 것입니다.

Python 클라이언트 (스크립트 쪽)

import socket
import json

SOCKET_PATH = "/tmp/myapp.sock"

def send_message(data: dict) -> dict:
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(SOCKET_PATH)

        # JSON으로 직렬화해서 전송
        payload = json.dumps(data).encode("utf-8")
        s.sendall(payload)
        s.shutdown(socket.SHUT_WR)  # 전송 완료 신호

        # 응답 수신
        response_data = b""
        while chunk := s.recv(4096):
            response_data += chunk

        return json.loads(response_data)

# 사용
response = send_message({"action": "approve", "toolName": "Bash"})
print(response)  # {"result": "allowed"}

Swift 서버 (앱 쪽) — JSON 메시지 처리

// 메시지 타입 정의
struct HookRequest: Decodable {
    let action: String
    let toolName: String
}

struct HookResponse: Encodable {
    let result: String
}

// 클라이언트로부터 수신한 데이터를 JSON으로 처리
private func handleClient(_ fd: Int32) {
    DispatchQueue.global().async {
        var accumulated = Data()
        var buffer = [UInt8](repeating: 0, count: 4096)

        // 전체 메시지 수신 (클라이언트가 SHUT_WR 전송 시까지)
        while true {
            let n = recv(fd, &buffer, buffer.count, 0)
            if n <= 0 { break }
            accumulated.append(contentsOf: buffer[0..<n])
        }

        // JSON 파싱
        if let request = try? JSONDecoder().decode(HookRequest.self, from: accumulated) {
            print("요청: \(request.action) - \(request.toolName)")

            // 응답 생성
            let response = HookResponse(result: "allowed")
            if let responseData = try? JSONEncoder().encode(response) {
                responseData.withUnsafeBytes { ptr in
                    _ = send(fd, ptr.baseAddress!, responseData.count, 0)
                }
            }
        }

        close(fd)
    }
}

표준 입출력(stdin/stdout) — 가장 단순한 IPC

부모 프로세스가 자식 프로세스를 실행할 때 stdin/stdout으로 통신하는 방법입니다.

import Foundation

// 자식 프로세스 실행 + stdin/stdout 통신
func runScript(input: String) async throws -> String {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
    process.arguments = ["/path/to/script.py"]

    let stdinPipe = Pipe()
    let stdoutPipe = Pipe()
    let stderrPipe = Pipe()

    process.standardInput = stdinPipe
    process.standardOutput = stdoutPipe
    process.standardError = stderrPipe

    try process.run()

    // stdin으로 데이터 전달
    if let data = input.data(using: .utf8) {
        stdinPipe.fileHandleForWriting.write(data)
        stdinPipe.fileHandleForWriting.closeFile()
    }

    // stdout 읽기
    let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
    process.waitUntilExit()

    guard let output = String(data: outputData, encoding: .utf8) else {
        throw NSError(domain: "ProcessError", code: -1)
    }
    return output.trimmingCharacters(in: .whitespacesAndNewlines)
}

// 사용
let result = try await runScript(input: "Hello from Swift")
print(result)

어떤 IPC 방식을 선택할까?

빠른 로컬 통신이 필요하고 동시 연결이 많음  →  Unix Domain Socket
단순한 단방향 데이터 전달                     →  stdin/stdout (Pipe)
Apple 플랫폼 전용, 권한 분리 필요             →  XPC
원격 통신 또는 HTTP 기반                      →  TCP + URLSession

핵심 요약

  • Unix Domain Socket: 파일 경로를 주소로 쓰는 로컬 전용 소켓. TCP보다 빠르고 권한 제어 용이
  • socket(AF_UNIX, SOCK_STREAM, 0)bindlistenaccept 순서로 서버 구성
  • Python 스크립트 ↔ Swift 앱 IPC 패턴: JSON 메시지 + Unix Domain Socket
  • Process + Pipe: 자식 프로세스 실행 후 stdin/stdout으로 단순 통신
  • TCP 소켓과 API가 거의 동일해 TCP를 알면 UDS도 쉽게 사용 가능

다음 편에서는 스트림 기반 소켓의 핵심 문제인 메시지 경계를 해결하는 길이-접두사 프레이밍과 실전 IPC 프로토콜 설계를 다룹니다.

🤖 Generated with Claude Code