[Swift 입문] 31편 — 길이-접두사 프레이밍과 IPC 프로토콜 설계

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [30편] Unix Domain Socket과 프로세스 간 통신

TCP 스트림의 근본 문제

앞 편에서 TCP가 “메시지 경계를 보장하지 않는 바이트 스트림”이라고 언급했습니다. 이것이 왜 문제인지 구체적으로 살펴봅니다.

보내는 쪽이 전송한 것:
  [메시지 A: "안녕하세요"] [메시지 B: "반갑습니다"]

받는 쪽이 수신할 수 있는 경우:
  경우 1: ["안녕하세요반갑습니다"]         (두 메시지가 합쳐짐)
  경우 2: ["안녕"] ["하세요반갑습니다"]    (첫 메시지가 쪼개짐)
  경우 3: ["안녕하세요"] ["반갑습니다"]    (정상 — 보장 안 됨)

이 문제를 해결해야 소켓 통신이 제대로 동작합니다.


메시지 프레이밍(Framing) 방법들

1. 구분자(Delimiter) 방식

메시지1\n메시지2\n메시지3\n

줄바꿈(\n)이나 특수 문자로 메시지를 구분합니다. HTTP/1.1, SMTP, FTP 등이 이 방식을 씁니다.

  • 장점: 구현 단순, 사람이 읽기 쉬움
  • 단점: 메시지 내용에 구분자가 포함될 수 없음 (이스케이프 필요)

2. 고정 길이 방식

모든 메시지가 정확히 256바이트
  • 장점: 구현 매우 단순
  • 단점: 유연성 없음, 낭비 심함

3. 길이-접두사(Length-Prefix) 방식 ← 가장 범용적

[ 4바이트: 길이 ][ 본문 데이터 ][ 4바이트: 길이 ][ 본문 데이터 ] ...

메시지 앞에 본문의 크기를 먼저 보냅니다. 받는 쪽은 먼저 4바이트를 읽어 길이를 파악한 뒤, 정확히 그만큼만 읽습니다.


길이-접두사 프레이밍 구현

인코딩 (메시지 감싸기)

import Foundation

enum FramingProtocol {
    // 메시지를 [4바이트 길이][본문] 형태로 감쌈
    static func encode(_ data: Data) -> Data {
        var frame = Data(capacity: 4 + data.count)

        // 길이를 빅엔디언(big-endian) 4바이트로 변환
        // 빅엔디언: 높은 자릿수 바이트가 먼저 오는 방식 (네트워크 표준)
        var length = UInt32(data.count).bigEndian
        frame.append(Data(bytes: &length, count: 4))
        frame.append(data)

        return frame
    }

    // JSON 객체를 바로 프레임으로 변환
    static func encode<T: Encodable>(_ value: T) throws -> Data {
        let json = try JSONEncoder().encode(value)
        return encode(json)
    }
}

디코딩 (스트림에서 메시지 추출)

// 수신 버퍼 — 스트림 데이터를 누적하며 완전한 메시지를 추출
class FrameDecoder {
    private var buffer = Data()

    // 새 데이터를 버퍼에 추가하고 완성된 메시지 목록을 반환
    func feed(_ data: Data) -> [Data] {
        buffer.append(data)
        return extractFrames()
    }

    private func extractFrames() -> [Data] {
        var messages: [Data] = []

        while buffer.count >= 4 {
            // 앞 4바이트에서 길이 읽기
            let length = buffer.prefix(4).withUnsafeBytes { ptr in
                UInt32(bigEndian: ptr.load(as: UInt32.self))
            }

            let totalNeeded = 4 + Int(length)

            // 버퍼에 전체 메시지가 도착했는지 확인
            guard buffer.count >= totalNeeded else {
                break  // 아직 데이터가 더 필요함 — 다음 수신까지 대기
            }

            // 본문 추출
            let messageData = buffer[4..<totalNeeded]
            messages.append(Data(messageData))

            // 처리한 데이터 제거
            buffer.removeFirst(totalNeeded)
        }

        return messages
    }
}

실전 사용 예시

// 메시지 타입 정의
struct AppMessage: Codable {
    let type: MessageType
    let payload: String

    enum MessageType: String, Codable {
        case request = "request"
        case response = "response"
        case event = "event"
    }
}

// 서버에서 메시지 처리
class IPCServer {
    private let decoder = FrameDecoder()

    func onDataReceived(_ data: Data, connection: Connection) {
        // 수신 데이터를 디코더에 넣으면 완성된 메시지만 나옴
        let messages = decoder.feed(data)

        for messageData in messages {
            handleMessage(messageData, connection: connection)
        }
    }

    private func handleMessage(_ data: Data, connection: Connection) {
        guard let message = try? JSONDecoder().decode(AppMessage.self, from: data) else {
            print("파싱 실패")
            return
        }

        switch message.type {
        case .request:
            let response = AppMessage(type: .response, payload: "처리 완료: \(message.payload)")
            if let frame = try? FramingProtocol.encode(response) {
                connection.send(frame)
            }
        case .event:
            print("이벤트 수신: \(message.payload)")
        default:
            break
        }
    }
}

엔디언(Endianness) 이해

길이를 바이트로 표현할 때 바이트 순서가 중요합니다.

let length: UInt32 = 1000  // = 0x000003E8

// 빅엔디언 (Big-Endian): [0x00, 0x00, 0x03, 0xE8]  ← 네트워크 표준
// 리틀엔디언 (Little-Endian): [0xE8, 0x03, 0x00, 0x00]  ← x86/ARM 기본

// 네트워크 통신에서는 항상 빅엔디언 사용 (네트워크 바이트 오더)
let bigEndian = length.bigEndian    // 전송 시
let original = UInt32(bigEndian: received)  // 수신 시

Swift의 .bigEndian은 값을 빅엔디언으로 변환하고, UInt32(bigEndian:)은 빅엔디언 값을 읽습니다. 양쪽이 같은 순서를 사용하면 언어나 플랫폼이 달라도 통신이 됩니다.


완성된 IPC 프로토콜 — Python ↔ Swift

Python 쪽 (송신)

import socket
import json
import struct

SOCKET_PATH = "/tmp/myapp.sock"

def send_request(action: str, data: dict) -> dict:
    payload = json.dumps({"type": "request", "action": action, "data": data})
    encoded = payload.encode("utf-8")

    # 길이-접두사 프레임
    frame = struct.pack(">I", len(encoded)) + encoded  # ">I" = 빅엔디언 uint32

    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(SOCKET_PATH)
        s.sendall(frame)

        # 응답 수신 (4바이트 길이 먼저)
        length_bytes = s.recv(4)
        length = struct.unpack(">I", length_bytes)[0]
        response_bytes = s.recv(length)

        return json.loads(response_bytes)

# 사용
result = send_request("tool_call", {"tool": "Bash", "command": "ls"})
print(result)  # {"decision": "allow"}

Swift 쪽 (수신 + 처리)

// 요청 타입
struct IPCRequest: Decodable {
    let type: String
    let action: String
    let data: [String: String]
}

// 응답 타입
struct IPCResponse: Encodable {
    let decision: String
}

// 연결 처리
func handleIPCConnection(_ fd: Int32) {
    var rawBuffer = Data()
    var buffer = [UInt8](repeating: 0, count: 4096)

    // 길이 헤더 읽기 (4바이트)
    var headerBytes = [UInt8](repeating: 0, count: 4)
    recv(fd, &headerBytes, 4, MSG_WAITALL)
    let length = UInt32(bigEndian: headerBytes.withUnsafeBytes {
        $0.load(as: UInt32.self)
    })

    // 본문 읽기
    var body = [UInt8](repeating: 0, count: Int(length))
    recv(fd, &body, Int(length), MSG_WAITALL)
    let bodyData = Data(body)

    // JSON 파싱
    guard let request = try? JSONDecoder().decode(IPCRequest.self, from: bodyData) else {
        close(fd); return
    }

    print("수신 요청: \(request.action)")

    // 응답 생성 및 전송
    let response = IPCResponse(decision: "allow")
    if let responseData = try? JSONEncoder().encode(response) {
        let frame = FramingProtocol.encode(responseData)
        frame.withUnsafeBytes { ptr in
            _ = send(fd, ptr.baseAddress!, frame.count, 0)
        }
    }

    close(fd)
}

MSG_WAITALL 플래그는 “지정한 바이트 수가 모두 올 때까지 기다려라”는 의미입니다. 길이를 알고 있을 때 편리합니다.


프로토콜 설계 체크리스트

  • 메시지 경계: 길이-접두사 또는 구분자로 프레임 구분
  • 엔디언 통일: 빅엔디언(네트워크 표준) 사용
  • 타입 필드: 메시지 종류를 구분하는 필드 포함
  • 에러 처리: 연결 끊김, 파싱 실패, 타임아웃 처리
  • 버전 필드: 양쪽 프로토콜 버전이 다를 때 대비

핵심 요약

  • TCP/UDS는 바이트 스트림 — 메시지 경계가 없음
  • 길이-접두사 프레이밍: 앞 4바이트에 본문 길이를 넣어 경계를 명시
  • FrameDecoder: 수신 버퍼에 데이터를 누적하며 완성된 메시지만 추출
  • 빅엔디언: 네트워크 통신의 표준 바이트 순서. Swift의 .bigEndian 사용
  • Python의 struct.pack(">I", n) ↔ Swift의 UInt32(n).bigEndian은 동등
  • MSG_WAITALL: 지정 바이트 수가 도착할 때까지 블록

6부 네트워킹과 IPC가 완결됩니다. 다음 편부터는 7부 시스템 프로그래밍으로, 셸 스크립트, PTY, LaunchAgent, AVFoundation 사운드 재생을 다룹니다.

🤖 Generated with Claude Code

답글 남기기

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