🤖 이 글은 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