ProtoBuf - 이게 뭔데 사람들은 환호성을 지를까?

2025. 11. 29. 22:03·SWIFT개발일지

우버나 카카오 네비 앱을 켜면 뭐가 보이나요?

 

지도 위에 내 위치가 표시되고, 주변 차량들이 실시간으로 움직이고, 예상 도착 시간이 계속 업데이트되죠.

실시간 위치 서비스, 얼마나 많은 데이터가 오갈까요?

가정을 하며 생각을 해볼게요.

1초마다:

  • 내 GPS 위치 → 서버
  • 주변 운전자 10명의 위치 ← 서버
  • 예상 도착 시간 재계산 ← 서버
  • 도로 교통 상황 ← 서버

한 명의 사용자만 봐도 이 정도인데, 동시에 100만 명, 아니 그 이상이 사용한다면? 🤯🧐

 


REST API로 실시간 위치를 추적한다면? -> 폴링 방식

// 1초마다 서버에 요청
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    fetchDriverLocations { locations in
        updateMapView(with: locations)
    }
}

func fetchDriverLocations(completion: @escaping ([Driver]) -> Void) {
    let url = URL(string: "https://api.uber.com/drivers/nearby")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        // JSON 파싱...
    }.resume()
}

우리에게 익숙한 방식이죠. 근데 이게 왜 문제일까요?

매 요청마다 발생하는 일들

  1. TCP 연결 맺기 (3-way handshake)
  2. TLS 협상 (HTTPS니까요)
  3. HTTP 헤더 전송 (수백 바이트)
  4. JSON 데이터 전송
  5. JSON 파싱
  6. 연결 끊기

실제로 측정해보면,

연결을 맺고 끊는 시간(약 150ms)이 실제 데이터를 전송하는 시간(약 50ms)보다 3배나 깁니다.

100만 명이 동시에 이렇게 하면?

서버는 데이터 처리보다 연결 관리에 더 많은 리소스를 쓰게 됩니다.

HTTP/1.1의 Keep-Alive로 연결을 재사용할 수 있긴 하지만, 여전히 Head-of-Line Blocking(앞선 요청이 늦어지면 뒤따르는 요청들도 대기) 문제는 남아있어요.

JSON의 Cost! 

운전자 위치 데이터를 JSON으로 보낸다고 생각해볼까요?

{
  "drivers": [
    {
      "driverId": "driver_123456",
      "latitude": 37.7749,
      "longitude": -122.4194,
      "heading": 45.0,
      "speed": 30.5,
      "timestamp": 1700000000,
      "status": "available"
    },
    // 주변 운전자 9명 더...
  ]
}

 

실제로 10명의 운전자 정보를 담으면 대략 1.5KB된다고 보죠

문제점들

1. 키 이름의 반복

  • "driverId", "latitude" 같은 문자열이 매번 전송됩니다
  • 10명의 운전자 정보면 같은 키가 10번씩 반복되죠
  • ex) driverId 하나만 해도 9바이트잖아요? 10번이면 90바이트죠!!

2. 숫자의 문자열 변환

  • 37.7749가 실제로는 "37.7749"로 전송됩니다
  • 숫자를 문자로 바꾸고, 다시 숫자로 파싱하는 과정이 필요합니다

3. 불필요한 문자들

  • {, }, :, ,, 공백... 이 모든 게 용량을 차지합니다

비용 계산

100만 명이 1초마다 1.5KB씩 받는다면...

1.5KB × 1,000,000명 = 1.5GB/초
1분이면 90GB
1시간이면 5.4TB
하루면 129.6TB


이건 데이터 전송 비용만...

클라우드 비용이 GB당 $0.09라고 가정하면:

129.6TB/일 = 132,710GB/일
132,710GB × $0.09 = $11,943/일
연간 약 $4,359,195 (약 58억원)

단순히 위치 데이터만 주고받는데 말이죠. 여기에 CPU 사용료(JSON 파싱), 메모리 사용료, 로드밸런서 비용 등을 더하면...

이제 왜 Google 같은 회사들이 고민했는지 보이시나요?🥹

 

Protocol Buffers는 어떻게 탄생했을까?

Google의 문제

 

2000년대 초반, Google도 비슷한 문제에 직면했습니다.

Gmail, Google Maps, YouTube... 수많은 서비스에서 엄청난 양의 데이터를 주고받아야 했죠.

당시에는 XML이 주로 사용되었는데, 이건 JSON보다 더 무거웠습니다.

<driver>
  <driverId>driver_123456</driverId>
  <latitude>37.7749</latitude>
  <longitude>-122.4194</longitude>
  <heading>45.0</heading>
  <speed>30.5</speed>
  <timestamp>1700000000</timestamp>
  <status>available</status>
</driver>


열고 닫는 태그가 두 번씩 반복되니 용량은 더 크고, 파싱은 더 느렸습니다.

 

Google이 고민한 것들

"어떻게 하면 데이터를 더 작게 만들 수 있을까?"

  • 텍스트 -> 바이너리로?
  • 키 이름 -> 번호로 대체한다면?
  • 필요한 데이터만 보낸다면?

"어떻게 하면 더 빠르게 처리할 수 있을까?"

  • 파싱 과정을 단순화한다면?
  • 타입이 미리 정해져 있다면?
  • 스키마 검증을 컴파일 타임에 한다면?

"어떻게 하면 여러 언어에서 쓸 수 있을까?"

  • 하나의 스키마를 정의하여 각 언어의 코드를 자동으로 생성한다면?

Protocol Buffers는 뭐가 다를까?

1. 스키마를 먼저 정의한다

JSON은 그냥 막 보내도 되잖아요? Protobuf는 다릅니다.

// driver.proto
syntax = "proto3";

message DriverLocation {
  string driver_id = 1;
  double latitude = 2;
  double longitude = 3;
  float heading = 4;
  float speed = 5;
  int64 timestamp = 6;
  DriverStatus status = 7;
}

enum DriverStatus {
  AVAILABLE = 0;
  BUSY = 1;
  OFFLINE = 2;
}
💡 참고: syntax는 proto 버전을 지정하는데, proto2와 proto3는 기본값과 설정이 다릅니다.
자세한 내용은 Proto3 공식 가이드를 참고하세요!
 

Language Guide (proto 3)

Covers how to use the proto3 revision of the Protocol Buffers language in your project.

protobuf.dev

 

"왜 귀찮게 이렇게 정의해야 해?"

이게 핵심입니다. 스키마를 정의하면:

  • 타입이 보장됩니다
    • latitude는 반드시 double 타입
    • status는 정해진 enum 값만 사용 가능
    • 잘못된 타입을 넣으면 컴파일 에러
  • 필드 번호로 식별합니다
    • "driver_id" 대신 숫자 1
    • 문자열을 매번 보내지 않아도 됨
    • 필드 번호는 영구적으로 유지됨
이렇게 되면 필드 이름을 바꿔도 괜찮아요. 예를 들어  driver_id를  driverId 로 바꿔도, 필드 번호 1번은 그대로니까 기존 클라이언트가 문제없이 동작합니다. 이게 바로 하위 호환성이에요!
    • 주의할점:!!!!@ 한번 쓴 필드 번호는 다시 재사용 하면 안됩니다!! 구버전 앱은 2번이 age를 받고 잇는데 신버전 서버가 gender를 2번에 넣어주면 충돌이 나겟죠
  • 여러 언어로 코드를 자동 생성합니다
    • Swift, Java, Go, Python...모두 같은 스키마를 공유

2. 바이너리 인코딩

같은 데이터가 실제로는 어떻게 전송될까요?

JSON (텍스트):

{"driverId":"driver_123","latitude":37.7749,"longitude":-122.4194}

크기: 약 70바이트

Protobuf (바이너리):

0A 0B 64 72 69 76 65 72 5F 31 32 33 11 40 42 E6 B7 ...

크기: 약 28바이트

동일한 데이터인데 2.5배 차이가 나는 거죠.

동일한 데이터인데 2.5배 차이!

JSON도 gzip 압축을 쓰는 경우가 많습니다.
압축된 JSON(약 50바이트)과 비교해도 Protobuf가 여전히 1.5~2배 작습니다.

 

TLV(tag - length -value) 인코딩 방식

 

Tag 계산 = (field_number << 3) | wire_type

와이어 타입:

  • 0: Varint (int32, int64, bool 등)
  • 1: 64-bit (double, fixed64)
  • 2: Length-delimited (string, bytes, embedded messages)
  • 5: 32-bit (float, fixed32)

예시: "driver_123" 인코딩

필드 1 (driver_id, string)
Tag = (1 << 3) | 2 = 0x0A

0x0A           // Tag: 필드 1, string 타입
0x0B           // Length: 11바이트
64 72 69 76... // Value: "driver_123"의 UTF-8 바이트

결과:

  • 키 이름 "driverId" (9바이트) → 태그 1바이트로 대체!
  • 9배 절약!

3. Varint: 작은 숫자는 더 작게

Protobuf는 정수를 효율적으로 인코딩합니다.

숫자 1:     0x01        (1바이트)
숫자 127:   0x7F        (1바이트)
숫자 128:   0x80 0x01   (2바이트)
숫자 16383: 0xFF 0x7F   (2바이트)

Varint 원리:

  • 7비트씩 사용하고, MSB는 continuation 비트
  • 작은 숫자는 1바이트만 사용!

JSON과 비교:

// JSON: 항상 문자열
{"count": 1}        // "1" = 1바이트
{"count": 1000000}  // "1000000" = 7바이트

// Protobuf: Varint
count = 1        // 0x01 = 1바이트
count = 1000000  // 0x80 0x89 0x7A = 3바이트

 

4. Proto3의 똑똑한 최적화

 

Proto3에서는 기본값을 가진 필드는 아예 직렬화하지 않습니다.

var location = DriverLocation()
location.latitude = 0      // double 기본값
location.speed = 0         // float 기본값
location.status = .available  // enum 기본값 (0)

// 이 세 필드는 바이너리에 포함되지 않아요!
// → 추가 용량 절약

실제 값이 없으면 보내지 않는 거죠. 받는 쪽에서는 필드가 없으면 자동으로 기본값을 사용하고요.

 

5. 파싱 속도

JSON 파싱 과정

// JSON 파싱 과정
// 1. 문자열 읽기: "{"
// 2. 키 파싱: "driverId"
// 3. 콜론 찾기: ":"
// 4. 값 파싱: "driver_123"
// 5. 타입 추론: String?
// 6. 객체 매핑
// ... 반복

let decoder = JSONDecoder()
let driver = try decoder.decode(Driver.self, from: jsonData)

Protobuf 디코딩:

// Protobuf 디코딩 과정
// 1. 태그 읽기: 0x0A (필드 1, string)
// 2. 길이 읽기: 0x0B (11바이트)
// 3. 값 읽기: 11바이트만큼
// 4. 필드 매핑: driver_id = ... (스키마가 이미 알고 있음)
// ... 반복

let driver = try DriverLocation(serializedData: data)

타입을 추론할 필요가 없고, 키를 찾을 필요도 없습니다. 그냥 필드 번호만 보고 바로 매핑하면 끝이에요.


그런데 Protocol Buffers만으로는 부족하다

데이터를 작게 만드는 것만으로는 충분하지 않습니다.

실시간 서비스는 지속적인 연결이 필요하거든요.

REST의 근본적인 한계

우리가 쓰는 일반적인 REST API는 HTTP/1.1 위에서 동작합니다. 여기에는 단점들이 있어요

  • Head-of-Line Blocking (줄 막힘 현상): 앞선 요청 처리가 늦어지면 뒤따르는 요청들도 줄줄이 대기해야 합니다.
  • 매번 맺고 끊는 연결: Stateless 특성 때문에 매 요청마다 TCP 핸드셰이크, TLS 협상, 헤더 전송을 반복합니다. 
  • 텍스트 기반 헤더: 바디(Body)는 Protobuf로 줄였어도, 헤더(Header)는 여전히 무거운 텍스트입니다. 쿠키나 인증 토큰 때문에 헤더가 바디보다 큰 배보다 배꼽이 더 큰 상황이 발생하죠.

 여기서 gRPC는 HTTP/2 에서 동작합니다.

HTTP/2의 장점:

  • 멀티플렉싱 (Multiplexing): 하나의 TCP 연결 안에서 여러 요청을 동시에 주고받습니다. 내 위치를 보내면서(Upstream), 동시에 친구들의 위치를 받을 수(Downstream) 있습니다. 줄 서서 기다릴 필요가 없죠.
  • HPACK 헤더 압축: 중복되는 헤더를 압축해서 보냅니다. "User-Agent", "Authorization" 같은 반복 데이터를 획기적으로 줄입니다.
  • 양방향 스트리밍: 이것이 핵심입니다. 연결을 한 번 맺어두고, 실시간으로 데이터를 물 흐르듯이 주고받습니다.

gRPC의 4가지 메서드 타입은 실제 앱 기능과 1:1로 매칭됩니다.

  1. Unary RPCs - 클라이언트가 단일 요청을 보내고 단일 응답을 받습니다
    client: 내 목표 범위 줘ㅓㅓ
    server: ㅇㅋ 80~100! (끝!)
  2. Server streaming RPCs - 클라이언트가 요청을 보내고 서버로부터 메시지 스트림을 읽습니다
    Client: 지금부터 혈당 데이터 계속 보내줘(1회 요청)
    Server: 120 mg/dL... (5분 뒤) 123 mg/dL... (5분 뒤) 125 mg/dL...(지속 응답)
  3. Client streaming RPCs - 클라이언트가 메시지 스트림을 작성하고 서버에 보냅니다
    Client가 지속적으로 전송하고 서버는 1회 응답
  4. Bidirectional streaming RPCs - 양쪽이 읽기-쓰기 스트림을 사용해 메시지 시퀀스를 보냅니다
    앱(Client Stream): 실시간 혈당 데이터와 운동량(걸음 수)을 1분마다 서버로 계속 보냅니다.
    서버(Server Stream): 데이터를 실시간으로 분석하다가, 급격한 혈당 하락이 예측되면 즉시 경고 알림이나 인슐린 펌프 제어 명령을 내려보냅니다.

 

Swift에서 Protocol Buffers 사용하기

환경 세팅

먼저 필요한 도구들을 설치합니다.

# Homebrew를 사용해 Protocol Buffer 컴파일러 설치
brew install protobuf

# Swift Protobuf 플러그인 설치
brew install swift-protobuf

1단계: .proto 파일 작성

프로젝트 폴더에 driver.proto 파일을 만듭니다.

// driver.proto
syntax = "proto3";

message DriverLocation {
  string driver_id = 1;
  double latitude = 2;
  double longitude = 3;
  float heading = 4;
  float speed = 5;
  int64 timestamp = 6;
  DriverStatus status = 7;
}

enum DriverStatus {
  AVAILABLE = 0;
  BUSY = 1;
  OFFLINE = 2;
}

2단계: Swift 코드 생성

터미널에서 protoc 컴파일러를 실행합니다.

protoc --swift_out=./Generated driver.proto

이 명령어가 ./Generated/driver.pb.swift 파일을 자동으로 생성해줍니다.

 

3단계: 생성된 Swift 코드

driver.pb.swift 파일을 열어보면 이런 코드가 자동으로 만들어져 있어요.

// driver.pb.swift (자동 생성됨)
import SwiftProtobuf

public struct DriverLocation {
    public var driverID: String = String()
    public var latitude: Double = 0
    public var longitude: Double = 0
    public var heading: Float = 0
    public var speed: Float = 0
    public var timestamp: Int64 = 0
    public var status: DriverStatus = .available
    
    public var unknownFields = SwiftProtobuf.UnknownStorage()
    
    public init() {}
}

public enum DriverStatus: SwiftProtobuf.Enum {
    public typealias RawValue = Int
    case available // = 0
    case busy // = 1
    case offline // = 2
    
    public init() {
        self = .available
    }
}

extension DriverLocation: SwiftProtobuf.Message {
    public static let protoMessageName: String = "DriverLocation"
    
    // 직렬화: Swift 객체 → 바이너리
    public func serializedData() throws -> Data {
        var visitor = BinaryEncodingVisitor()
        try traverse(visitor: &visitor)
        return visitor.serializedData
    }
    
    // 역직렬화: 바이너리 → Swift 객체
    public init(serializedData: Data) throws {
        self.init()
        try merge(serializedData: serializedData)
    }
}

보시다시피 우리가 .proto 파일에 정의한 구조가 Swift struct와 enum으로 그대로 변환되었어요.

주목할 점:

  • 필드 이름이 Swift naming convention으로 자동 변환 (driver_id → driverID)
  • 모든 필드가 기본값을 가지고 있음 (Proto3의 특성)
  • serializedData()와 init(serializedData:) 메서드가 자동 생성됨

실제 사용 - 직렬화 (Serialization)

이제 이 생성된 코드를 실제로 사용해볼까요?

 

실제 사용

import SwiftProtobuf

// 1. Swift 객체 생성
var location = DriverLocation()
location.driverID = "driver_123"
location.latitude = 37.7749
location.longitude = -122.4194
location.heading = 45.0
location.speed = 30.5
location.timestamp = Int64(Date().timeIntervalSince1970)
location.status = .available

// 2. 바이너리로 직렬화
do {
    let binaryData = try location.serializedData()
    print("직렬화된 크기: \(binaryData.count) bytes")
    
    // 네트워크로 전송하거나 파일에 저장
    sendToServer(binaryData)
    // 또는
    // try binaryData.write(to: fileURL)
    
} catch {
    print("직렬화 실패: \(error)")
}

내부적으로 무슨 일이 일어날까요?

serializedData() 메서드는 각 필드를 TLV 형식으로 인코딩합니다.

// 실제 내부 구현 (단순화한 버전)
func serializedData() throws -> Data {
    var output = Data()
    
    // 필드 1: driver_id (string)
    if !driverID.isEmpty {
        output.append(0x0A)  // Tag: (1 << 3) | 2 = 10
        let utf8 = driverID.utf8
        output.append(UInt8(utf8.count))  // Length
        output.append(contentsOf: utf8)   // Value
    }
    
    // 필드 2: latitude (double)
    if latitude != 0 {
        output.append(0x11)  // Tag: (2 << 3) | 1 = 17
        var value = latitude.bitPattern
        withUnsafeBytes(of: &value) { bytes in
            output.append(contentsOf: bytes)  // 8 bytes
        }
    }
    
    // 필드 3: longitude (double)
    if longitude != 0 {
        output.append(0x19)  // Tag: (3 << 3) | 1 = 25
        var value = longitude.bitPattern
        withUnsafeBytes(of: &value) { bytes in
            output.append(contentsOf: bytes)  // 8 bytes
        }
    }
    
    // 필드 6: timestamp (int64, varint로 인코딩)
    if timestamp != 0 {
        output.append(0x30)  // Tag: (6 << 3) | 0 = 48
        appendVarint(timestamp, to: &output)
    }
    
    // 필드 7: status (enum, varint로 인코딩)
    if status != .available {  // 기본값 0이 아니면
        output.append(0x38)  // Tag: (7 << 3) | 0 = 56
        appendVarint(Int64(status.rawValue), to: &output)
    }
    
    return output
}

여기서 중요한 포인트:

  1. 기본값은 인코딩하지 않습니다 - latitude가 0이면 아예 바이너리에 포함되지 않아요
  2. 필드 순서는 상관없습니다 - 태그 번호로 식별하니까요
  3. Varint 인코딩 - 작은 정수는 1바이트로, 큰 정수도 필요한 만큼

실제 사용 - 역직렬화 (Deserialization)

반대로 서버에서 받은 바이너리 데이터를 Swift 객체로 변환하는 과정을 볼까요??

// 네트워크에서 받은 데이터
let receivedData: Data = fetchFromServer()

// 바이너리 → Swift 객체로 역직렬화
do {
    let decodedLocation = try DriverLocation(serializedData: receivedData)
    
    print("운전자 ID: \(decodedLocation.driverID)")
    print("위도: \(decodedLocation.latitude)")
    print("경도: \(decodedLocation.longitude)")
    print("속도: \(decodedLocation.speed) km/h")
    print("상태: \(decodedLocation.status)")
    
    // UI 업데이트
    updateMapPin(
        id: decodedLocation.driverID,
        coordinate: CLLocationCoordinate2D(
            latitude: decodedLocation.latitude,
            longitude: decodedLocation.longitude
        )
    )
    
} catch {
    print("역직렬화 실패: \(error)")
}

내부적으로 무슨 일이 일어날까요?

// 실제 내부 구현 (단순화한 버전)
init(serializedData: Data) throws {
    self.init()  // 기본값으로 초기화
    
    var index = 0
    
    while index < serializedData.count {
        // 1. Tag 읽기
        let tag = serializedData[index]
        index += 1
        
        let fieldNumber = Int(tag >> 3)
        let wireType = tag & 0x07
        
        // 2. 필드 번호에 따라 처리
        switch fieldNumber {
        case 1:  // driver_id
            let length = Int(serializedData[index])
            index += 1
            let bytes = serializedData[index..<index+length]
            driverID = String(decoding: bytes, as: UTF8.self)
            index += length
            
        case 2:  // latitude
            let bytes = serializedData[index..<index+8]
            latitude = Double(bitPattern: UInt64(bytes))
            index += 8
            
        case 3:  // longitude
            let bytes = serializedData[index..<index+8]
            longitude = Double(bitPattern: UInt64(bytes))
            index += 8
            
        case 6:  // timestamp
            let (value, bytesRead) = readVarint(from: serializedData, at: index)
            timestamp = Int64(value)
            index += bytesRead
            
        case 7:  // status
            let (value, bytesRead) = readVarint(from: serializedData, at: index)
            status = DriverStatus(rawValue: Int(value)) ?? .available
            index += bytesRead
            
        default:
            // 알 수 없는 필드는 unknownFields에 저장
            // (하위 호환성을 위해)
            break
        }
    }
}

여기서 주목할 점:

  1. 순서에 상관없이 파싱 - 태그 번호만 보고 필드를 찾아요
  2. 타입 검증 불필요 - 스키마가 이미 타입을 알고 있으니까요
  3. 알 수 없는 필드는 보존 - 서버가 새 필드를 추가해도 구버전 앱이 깨지지 않아요

 

'SWIFT개발일지' 카테고리의 다른 글

아 지겹다 복붙! Xcode 커스텀 템플릿 만들기  (1) 2025.11.25
BLE 완전 기초: CoreBluetooth를 이해하기 위한 필수 개념  (0) 2025.11.16
이미지 URL 저장 시 마주하는 함정 문제들  (0) 2025.09.11
Metal3편 - 메모리 사용량 급증 버그 수정  (0) 2025.04.20
이미지 최적화 3탄(kingFisher를 삭제하고 Custom)  (0) 2025.04.16
'SWIFT개발일지' 카테고리의 다른 글
  • 아 지겹다 복붙! Xcode 커스텀 템플릿 만들기
  • BLE 완전 기초: CoreBluetooth를 이해하기 위한 필수 개념
  • 이미지 URL 저장 시 마주하는 함정 문제들
  • Metal3편 - 메모리 사용량 급증 버그 수정
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (137) N
      • SWIFT개발일지 (31) N
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (42)
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (2)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
ProtoBuf - 이게 뭔데 사람들은 환호성을 지를까?
상단으로

티스토리툴바