๐Ÿ“ถ Core Bluetooth

BLE ๊ธฐ๊ธฐ ์Šค์บ”, ์—ฐ๊ฒฐ, ๋ฐ์ดํ„ฐ ํ†ต์‹  ํ”„๋ ˆ์ž„์›Œํฌ

iOS 5+BLE 5.0

โœจ Core Bluetooth๋ž€?

Core Bluetooth๋Š” Bluetooth Low Energy(BLE) ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ณ€ ๊ธฐ๊ธฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์—ฐ๊ฒฐํ•˜๋ฉฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ์Šค๋งˆํŠธ์›Œ์น˜, ํ”ผํŠธ๋‹ˆ์Šค ํŠธ๋ž˜์ปค, ์„ผ์„œ, IoT ๊ธฐ๊ธฐ์™€ ํ†ต์‹ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, GATT ์„œ๋น„์Šค์™€ ํŠน์„ฑ(Characteristic)์„ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ณ  ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Central-Peripheral ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: BLE ๊ธฐ๊ธฐ ์Šค์บ” ยท Peripheral ์—ฐ๊ฒฐ ยท GATT ์„œ๋น„์Šค ํƒ์ƒ‰ ยท ํŠน์„ฑ ์ฝ๊ธฐ/์“ฐ๊ธฐ ยท Notification ๊ตฌ๋… ยท ๋ฐฐํ„ฐ๋ฆฌ ํšจ์œจ์  ํ†ต์‹  ยท ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋™์ž‘

๐Ÿ”Œ 1. CBCentralManager ์ดˆ๊ธฐํ™”

Central ์—ญํ• ๋กœ BLE ๊ธฐ๊ธฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์—ฐ๊ฒฐํ•˜๋Š” ๋งค๋‹ˆ์ €๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

BluetoothManager.swift โ€” Central Manager ์ดˆ๊ธฐํ™”
import CoreBluetooth
import SwiftUI

@Observable
class BluetoothManager: NSObject {
    private var centralManager: CBCentralManager!
    var discoveredPeripherals: [CBPeripheral] = []
    var connectedPeripheral: CBPeripheral?
    var isBluetoothOn = false
    var isScanning = false

    override init() {
        super.init()
        // Central Manager ์ƒ์„ฑ
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    // ์Šค์บ” ์‹œ์ž‘
    func startScanning() {
        guard isBluetoothOn else {
            print("โš ๏ธ Bluetooth๊ฐ€ ๊บผ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค")
            return
        }

        discoveredPeripherals.removeAll()
        isScanning = true

        // ๋ชจ๋“  BLE ๊ธฐ๊ธฐ ์Šค์บ” (์„œ๋น„์Šค ํ•„ํ„ฐ ์—†์Œ)
        centralManager.scanForPeripherals(
            withServices: nil,
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )

        print("๐Ÿ” BLE ๊ธฐ๊ธฐ ์Šค์บ” ์‹œ์ž‘")
    }

    // ํŠน์ • ์„œ๋น„์Šค๋งŒ ์Šค์บ”
    func scanForDevices(withServices serviceUUIDs: [CBUUID]) {
        guard isBluetoothOn else { return }

        discoveredPeripherals.removeAll()
        isScanning = true

        centralManager.scanForPeripherals(withServices: serviceUUIDs, options: nil)
        print("๐Ÿ” ํŠน์ • ์„œ๋น„์Šค ์Šค์บ”: \(serviceUUIDs)")
    }

    // ์Šค์บ” ์ค‘์ง€
    func stopScanning() {
        centralManager.stopScan()
        isScanning = false
        print("โน๏ธ ์Šค์บ” ์ค‘์ง€")
    }

    // Peripheral ์—ฐ๊ฒฐ
    func connect(peripheral: CBPeripheral) {
        stopScanning()
        centralManager.connect(peripheral, options: nil)
        print("๐Ÿ”— ์—ฐ๊ฒฐ ์‹œ๋„: \(peripheral.name ?? "Unknown")")
    }

    // ์—ฐ๊ฒฐ ํ•ด์ œ
    func disconnect() {
        guard let peripheral = connectedPeripheral else { return }
        centralManager.cancelPeripheralConnection(peripheral)
        print("โŒ ์—ฐ๊ฒฐ ํ•ด์ œ")
    }
}

๐Ÿ“ก 2. Central Manager ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ

Bluetooth ์ƒํƒœ ๋ณ€ํ™”์™€ ๊ธฐ๊ธฐ ๋ฐœ๊ฒฌ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

BluetoothManager+Delegate.swift โ€” Central ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ
import CoreBluetooth

extension BluetoothManager: CBCentralManagerDelegate {
    // Bluetooth ์ƒํƒœ ๋ณ€ํ™”
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            print("โœ… Bluetooth ์ผœ์ง")
            isBluetoothOn = true
        case .poweredOff:
            print("โŒ Bluetooth ๊บผ์ง")
            isBluetoothOn = false
        case .unauthorized:
            print("โš ๏ธ Bluetooth ๊ถŒํ•œ ์—†์Œ")
            isBluetoothOn = false
        case .unsupported:
            print("โŒ Bluetooth ๋ฏธ์ง€์›")
            isBluetoothOn = false
        case .resetting:
            print("๐Ÿ”„ Bluetooth ์žฌ์„ค์ • ์ค‘")
        case .unknown:
            print("โ“ Bluetooth ์ƒํƒœ ๋ถˆ๋ช…")
        @unknown default:
            break
        }
    }

    // Peripheral ๋ฐœ๊ฒฌ
    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        // ์ด๋ฏธ ๋ฐœ๊ฒฌ๋œ ๊ธฐ๊ธฐ๋Š” ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ
        guard !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) else {
            return
        }

        discoveredPeripherals.append(peripheral)

        let deviceName = peripheral.name ?? "Unknown Device"
        let signalStrength = RSSI.intValue
        print("๐Ÿ“ถ ๋ฐœ๊ฒฌ: \(deviceName) (RSSI: \(signalStrength)dBm)")

        // Advertisement Data ์ถœ๋ ฅ
        if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
            print("  Local Name: \(localName)")
        }
        if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
            print("  Services: \(serviceUUIDs)")
        }
    }

    // ์—ฐ๊ฒฐ ์„ฑ๊ณต
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("โœ… ์—ฐ๊ฒฐ ์„ฑ๊ณต: \(peripheral.name ?? "Unknown")")
        connectedPeripheral = peripheral
        peripheral.delegate = self

        // ์„œ๋น„์Šค ํƒ์ƒ‰
        peripheral.discoverServices(nil)
    }

    // ์—ฐ๊ฒฐ ์‹คํŒจ
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        print("โŒ ์—ฐ๊ฒฐ ์‹คํŒจ: \(error?.localizedDescription ?? "Unknown error")")
        connectedPeripheral = nil
    }

    // ์—ฐ๊ฒฐ ํ•ด์ œ
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        print("โŒ ์—ฐ๊ฒฐ ํ•ด์ œ: \(peripheral.name ?? "Unknown")")
        connectedPeripheral = nil

        if let error = error {
            print("  Error: \(error.localizedDescription)")
        }
    }
}

๐Ÿ› ๏ธ 3. Peripheral ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ

์„œ๋น„์Šค์™€ ํŠน์„ฑ(Characteristic)์„ ํƒ์ƒ‰ํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์Šต๋‹ˆ๋‹ค.

BluetoothManager+Peripheral.swift โ€” Peripheral ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ
import CoreBluetooth

extension BluetoothManager: CBPeripheralDelegate {
    // ์„œ๋น„์Šค ๋ฐœ๊ฒฌ
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let error = error {
            print("โŒ ์„œ๋น„์Šค ๋ฐœ๊ฒฌ ์‹คํŒจ: \(error.localizedDescription)")
            return
        }

        guard let services = peripheral.services else { return }

        print("๐Ÿ” ๋ฐœ๊ฒฌ๋œ ์„œ๋น„์Šค: \(services.count)๊ฐœ")

        for service in services {
            print("  Service UUID: \(service.uuid)")
            // ๊ฐ ์„œ๋น„์Šค์˜ ํŠน์„ฑ ํƒ์ƒ‰
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }

    // ํŠน์„ฑ ๋ฐœ๊ฒฌ
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let error = error {
            print("โŒ ํŠน์„ฑ ๋ฐœ๊ฒฌ ์‹คํŒจ: \(error.localizedDescription)")
            return
        }

        guard let characteristics = service.characteristics else { return }

        print("๐Ÿ” ์„œ๋น„์Šค \(service.uuid)์˜ ํŠน์„ฑ: \(characteristics.count)๊ฐœ")

        for characteristic in characteristics {
            print("  Characteristic UUID: \(characteristic.uuid)")
            print("  Properties: \(characteristic.properties)")

            // ์ฝ๊ธฐ ๊ฐ€๋Šฅํ•˜๋ฉด ์ฝ๊ธฐ
            if characteristic.properties.contains(.read) {
                peripheral.readValue(for: characteristic)
            }

            // Notify ๊ฐ€๋Šฅํ•˜๋ฉด ๊ตฌ๋…
            if characteristic.properties.contains(.notify) {
                peripheral.setNotifyValue(true, for: characteristic)
                print("  ๐Ÿ“ฌ Notification ๊ตฌ๋…")
            }
        }
    }

    // ํŠน์„ฑ ๊ฐ’ ์ฝ๊ธฐ ์™„๋ฃŒ
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("โŒ ๊ฐ’ ์ฝ๊ธฐ ์‹คํŒจ: \(error.localizedDescription)")
            return
        }

        guard let data = characteristic.value else {
            print("โš ๏ธ ๋ฐ์ดํ„ฐ ์—†์Œ")
            return
        }

        print("๐Ÿ“ฅ ๋ฐ์ดํ„ฐ ์ˆ˜์‹  (\(characteristic.uuid)): \(data.hexString)")

        // String์œผ๋กœ ๋ณ€ํ™˜ ์‹œ๋„
        if let stringValue = String(data: data, encoding: .utf8) {
            print("  String: \(stringValue)")
        }

        // ํŠน์ • ์„œ๋น„์Šค๋ณ„ ํŒŒ์‹ฑ
        parseCharacteristicValue(characteristic, data: data)
    }

    // ํŠน์„ฑ ๊ฐ’ ์“ฐ๊ธฐ ์™„๋ฃŒ
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("โŒ ์“ฐ๊ธฐ ์‹คํŒจ: \(error.localizedDescription)")
            return
        }

        print("โœ… ์“ฐ๊ธฐ ์„ฑ๊ณต (\(characteristic.uuid))")
    }

    // Notification ์ƒํƒœ ๋ณ€๊ฒฝ
    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("โŒ Notification ์„ค์ • ์‹คํŒจ: \(error.localizedDescription)")
            return
        }

        if characteristic.isNotifying {
            print("โœ… Notification ํ™œ์„ฑํ™” (\(characteristic.uuid))")
        } else {
            print("โŒ Notification ๋น„ํ™œ์„ฑํ™” (\(characteristic.uuid))")
        }
    }

    // ํŠน์„ฑ ๊ฐ’ ํŒŒ์‹ฑ (์„œ๋น„์Šค๋ณ„ ์ฒ˜๋ฆฌ)
    private func parseCharacteristicValue(_ characteristic: CBCharacteristic, data: Data) {
        // Heart Rate Service (0x180D)
        if characteristic.uuid == CBUUID(string: "2A37") {
            let heartRate = parseHeartRate(data)
            print("  โค๏ธ Heart Rate: \(heartRate) bpm")
        }
        // Battery Level (0x2A19)
        else if characteristic.uuid == CBUUID(string: "2A19") {
            if let batteryLevel = data.first {
                print("  ๐Ÿ”‹ Battery: \(batteryLevel)%")
            }
        }
    }

    // Heart Rate ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ
    private func parseHeartRate(_ data: Data) -> Int {
        guard data.count > 1 else { return 0 }
        let flags = data[0]
        let is16Bit = (flags & 0x01) == 0x01

        if is16Bit {
            return Int(data[1]) | (Int(data[2]) << 8)
        } else {
            return Int(data[1])
        }
    }
}

// Data Extension
extension Data {
    var hexString: String {
        map { String(format: "%02X", $0) }.joined(separator: " ")
    }
}

๐Ÿ“ค 4. ๋ฐ์ดํ„ฐ ์“ฐ๊ธฐ

Peripheral์— ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•ฉ๋‹ˆ๋‹ค.

BluetoothManager+Write.swift โ€” ๋ฐ์ดํ„ฐ ์“ฐ๊ธฐ
import CoreBluetooth

extension BluetoothManager {
    // ๋ฌธ์ž์—ด ๋ฐ์ดํ„ฐ ์ „์†ก
    func writeString(_ string: String, to characteristic: CBCharacteristic) {
        guard let data = string.data(using: .utf8) else {
            print("โŒ ๋ฌธ์ž์—ด ๋ณ€ํ™˜ ์‹คํŒจ")
            return
        }

        writeData(data, to: characteristic)
    }

    // ๋ฐ”์ดํŠธ ๋ฐ์ดํ„ฐ ์ „์†ก
    func writeData(_ data: Data, to characteristic: CBCharacteristic) {
        guard let peripheral = connectedPeripheral else {
            print("โŒ ์—ฐ๊ฒฐ๋œ ๊ธฐ๊ธฐ ์—†์Œ")
            return
        }

        // ์“ฐ๊ธฐ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
        guard characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else {
            print("โŒ ์“ฐ๊ธฐ ๋ถˆ๊ฐ€๋Šฅํ•œ ํŠน์„ฑ")
            return
        }

        // ์“ฐ๊ธฐ ํƒ€์ž… ๊ฒฐ์ •
        let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.write) ? .withResponse : .withoutResponse

        peripheral.writeValue(data, for: characteristic, type: writeType)
        print("๐Ÿ“ค ๋ฐ์ดํ„ฐ ์ „์†ก: \(data.hexString)")
    }

    // LED ์ œ์–ด ์˜ˆ์ œ
    func toggleLED(characteristic: CBCharacteristic, isOn: Bool) {
        let command: UInt8 = isOn ? 0x01 : 0x00
        let data = Data([command])
        writeData(data, to: characteristic)
    }
}

๐Ÿ“ฑ 5. SwiftUI ํ†ตํ•ฉ

BLE ์Šค์บ๋„ˆ UI๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

BLEScannerView.swift โ€” BLE ์Šค์บ๋„ˆ UI
import SwiftUI
import CoreBluetooth

struct BLEScannerView: View {
    @State private var bluetoothManager = BluetoothManager()

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // ์ƒํƒœ ํ‘œ์‹œ
                statusHeader

                // ๊ธฐ๊ธฐ ๋ชฉ๋ก
                if bluetoothManager.discoveredPeripherals.isEmpty {
                    emptyState
                } else {
                    deviceList
                }
            }
            .navigationTitle("BLE ์Šค์บ๋„ˆ")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    scanButton
                }
            }
        }
    }

    // ์ƒํƒœ ํ—ค๋”
    private var statusHeader: some View {
        VStack(spacing: 8) {
            HStack {
                Circle()
                    .fill(bluetoothManager.isBluetoothOn ? Color.green : Color.red)
                    .frame(width: 12, height: 12)

                Text(bluetoothManager.isBluetoothOn ? "Bluetooth ํ™œ์„ฑ" : "Bluetooth ๋น„ํ™œ์„ฑ")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                Spacer()

                if bluetoothManager.isScanning {
                    ProgressView()
                        .controlSize(.small)
                    Text("์Šค์บ” ์ค‘...")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .padding()

            if let connected = bluetoothManager.connectedPeripheral {
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                    Text("์—ฐ๊ฒฐ๋จ: \(connected.name ?? "Unknown")")
                        .font(.subheadline)
                    Spacer()
                    Button("์—ฐ๊ฒฐ ํ•ด์ œ") {
                        bluetoothManager.disconnect()
                    }
                    .font(.caption)
                    .buttonStyle(.bordered)
                }
                .padding(.horizontal)
                .padding(.bottom)
            }
        }
        .background(Color(.systemGroupedBackground))
    }

    // ์Šค์บ” ๋ฒ„ํŠผ
    private var scanButton: some View {
        Button {
            if bluetoothManager.isScanning {
                bluetoothManager.stopScanning()
            } else {
                bluetoothManager.startScanning()
            }
        } label: {
            Image(systemName: bluetoothManager.isScanning ? "stop.circle" : "arrow.clockwise")
        }
        .disabled(!bluetoothManager.isBluetoothOn)
    }

    // ๋นˆ ์ƒํƒœ
    private var emptyState: some View {
        ContentUnavailableView {
            Label("BLE ๊ธฐ๊ธฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", systemImage: "antenna.radiowaves.left.and.right.slash")
        } description: {
            Text("์Šค์บ” ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ฃผ๋ณ€ BLE ๊ธฐ๊ธฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”")
        }
    }

    // ๊ธฐ๊ธฐ ๋ชฉ๋ก
    private var deviceList: some View {
        List(bluetoothManager.discoveredPeripherals, id: \.identifier) { peripheral in
            Button {
                bluetoothManager.connect(peripheral: peripheral)
            } label: {
                HStack {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(peripheral.name ?? "Unknown Device")
                            .font(.headline)

                        Text(peripheral.identifier.uuidString)
                            .font(.caption)
                            .foregroundStyle(.secondary)

                        if peripheral.state == .connected {
                            Text("์—ฐ๊ฒฐ๋จ")
                                .font(.caption2)
                                .foregroundStyle(.green)
                        }
                    }

                    Spacer()

                    Image(systemName: "chevron.right")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .disabled(peripheral.state == .connected)
        }
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: ์Šค์บ” ์‹œ allowDuplicatesKey๋ฅผ false๋กœ ์„ค์ •ํ•˜๋ฉด ๋ฐฐํ„ฐ๋ฆฌ ์†Œ๋ชจ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํŠน์ • ์„œ๋น„์Šค UUID๋กœ ํ•„ํ„ฐ๋งํ•˜๋ฉด ๋” ๋น ๋ฅด๊ฒŒ ์›ํ•˜๋Š” ๊ธฐ๊ธฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ํ›„์—๋Š” ๋ฐ˜๋“œ์‹œ ์Šค์บ”์„ ์ค‘์ง€ํ•˜์„ธ์š”.