๐ MultipeerConnectivity
๊ทผ๊ฑฐ๋ฆฌ P2P ํต์ ๋ฐ ๋ฐ์ดํฐ ๊ณต์ ํ๋ ์์ํฌ
iOS 7+Wi-Fi/BLE ์๋ ์ ํ
โจ MultipeerConnectivity๋?
MultipeerConnectivity๋ Wi-Fi, Bluetooth, P2P Wi-Fi๋ฅผ ์ฌ์ฉํ์ฌ ๊ทผ์ฒ์ ์๋ ๊ธฐ๊ธฐ๋ค ๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ๋ ํ๋ ์์ํฌ์ ๋๋ค. ์ธํฐ๋ท ์ฐ๊ฒฐ ์์ด๋ ์ต๋ 8๋์ ๊ธฐ๊ธฐ๊ฐ ad-hoc ๋คํธ์ํฌ๋ฅผ ๊ตฌ์ฑํ ์ ์์ผ๋ฉฐ, ํ ์คํธ, ํ์ผ, ์คํธ๋ฆผ ๋ฐ์ดํฐ๋ฅผ ์์ ์ ์ผ๋ก ์ ์กํฉ๋๋ค. AirDrop๊ณผ ์ ์ฌํ ๊ธฐ๋ฅ์ ์ฑ์ ์ง์ ๊ตฌํํ ์ ์์ต๋๋ค.
๐ก ํต์ฌ ๊ธฐ๋ฅ: Peer-to-Peer ํต์ ยท ์๋ ๊ธฐ๊ธฐ ๊ฒ์ ยท Wi-Fi/BLE ์๋ ์ ํ ยท ํ์ผ ์ ์ก ยท ์คํธ๋ฆผ ๋ฐ์ดํฐ ยท ์ต๋ 8๋ ์ฐ๊ฒฐ ยท ์ํธํ ํต์
๐ก 1. MCSession ์ด๊ธฐํ
Peer-to-Peer ์ธ์ ์ ์์ฑํ๊ณ ๊ด๋ฆฌํฉ๋๋ค.
MultipeerManager.swift โ ์ธ์
์ด๊ธฐํ
import MultipeerConnectivity import SwiftUI @Observable class MultipeerManager: NSObject { private let serviceType = "hig-lab-chat" // 15์ ์ดํ, ์๋ฌธ์/์ซ์/ํ์ดํ๋ง private var myPeerID: MCPeerID private var session: MCSession private var advertiser: MCNearbyServiceAdvertiser? private var browser: MCNearbyServiceBrowser? var connectedPeers: [MCPeerID] = [] var availablePeers: [MCPeerID] = [] var receivedMessages: [String] = [] var isAdvertising = false var isBrowsing = false override init() { // Peer ID ์์ฑ (๊ธฐ๊ธฐ ์ด๋ฆ ์ฌ์ฉ) myPeerID = MCPeerID(displayName: UIDevice.current.name) // ์ธ์ ์์ฑ session = MCSession( peer: myPeerID, securityIdentity: nil, encryptionPreference: .required ) super.init() session.delegate = self print("๐ฑ Peer ID: \(myPeerID.displayName)") } // Advertiser ์์ (๋ค๋ฅธ ๊ธฐ๊ธฐ๊ฐ ๋๋ฅผ ๋ฐ๊ฒฌํ ์ ์๊ฒ) func startAdvertising() { advertiser = MCNearbyServiceAdvertiser( peer: myPeerID, discoveryInfo: ["status": "available"], serviceType: serviceType ) advertiser?.delegate = self advertiser?.startAdvertisingPeer() isAdvertising = true print("๐ข Advertising ์์") } // Advertiser ์ค์ง func stopAdvertising() { advertiser?.stopAdvertisingPeer() advertiser = nil isAdvertising = false print("โน๏ธ Advertising ์ค์ง") } // Browser ์์ (๋ค๋ฅธ ๊ธฐ๊ธฐ ๊ฒ์) func startBrowsing() { browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType) browser?.delegate = self browser?.startBrowsingForPeers() isBrowsing = true print("๐ Browsing ์์") } // Browser ์ค์ง func stopBrowsing() { browser?.stopBrowsingForPeers() browser = nil isBrowsing = false print("โน๏ธ Browsing ์ค์ง") } // Peer์๊ฒ ์ด๋ ๋ณด๋ด๊ธฐ func invitePeer(_ peerID: MCPeerID) { guard let browser = browser else { return } browser.invitePeer( peerID, to: session, withContext: nil, timeout: 30 ) print("โ๏ธ ์ด๋ ์ ์ก: \(peerID.displayName)") } // ์ฐ๊ฒฐ ํด์ func disconnect() { session.disconnect() connectedPeers.removeAll() print("โ ์ฐ๊ฒฐ ํด์ ") } }
๐ฌ 2. ๋ฉ์์ง ์ ์ก
์ฐ๊ฒฐ๋ Peer๋ค์๊ฒ ๋ฐ์ดํฐ๋ฅผ ์ ์กํฉ๋๋ค.
MultipeerManager+Send.swift โ ๋ฐ์ดํฐ ์ ์ก
import MultipeerConnectivity extension MultipeerManager { // ํ ์คํธ ๋ฉ์์ง ์ ์ก func sendMessage(_ message: String) { guard !connectedPeers.isEmpty else { print("โ ๏ธ ์ฐ๊ฒฐ๋ Peer๊ฐ ์์ต๋๋ค") return } guard let data = message.data(using: .utf8) else { return } do { // ๋ชจ๋ ์ฐ๊ฒฐ๋ Peer์๊ฒ ์ ์ก try session.send( data, toPeers: connectedPeers, with: .reliable ) print("๐ค ๋ฉ์์ง ์ ์ก: \(message)") } catch { print("โ ์ ์ก ์คํจ: \(error.localizedDescription)") } } // ํน์ Peer์๊ฒ๋ง ์ ์ก func sendMessage(_ message: String, to peer: MCPeerID) { guard let data = message.data(using: .utf8) else { return } do { try session.send(data, toPeers: [peer], with: .reliable) print("๐ค ๋ฉ์์ง ์ ์ก (\(peer.displayName)): \(message)") } catch { print("โ ์ ์ก ์คํจ: \(error.localizedDescription)") } } // ํ์ผ ์ ์ก func sendFile(at url: URL, to peer: MCPeerID, withName name: String) { session.sendResource( at: url, withName: name, toPeer: peer, withCompletionHandler: { error in if let error = error { print("โ ํ์ผ ์ ์ก ์คํจ: \(error.localizedDescription)") } else { print("โ ํ์ผ ์ ์ก ์๋ฃ: \(name)") } } ) print("๐ ํ์ผ ์ ์ก ์์: \(name)") } // ์ด๋ฏธ์ง ์ ์ก func sendImage(_ image: UIImage) { guard !connectedPeers.isEmpty else { return } guard let imageData = image.jpegData(compressionQuality: 0.8) else { return } do { try session.send(imageData, toPeers: connectedPeers, with: .reliable) print("๐ผ๏ธ ์ด๋ฏธ์ง ์ ์ก ์๋ฃ") } catch { print("โ ์ด๋ฏธ์ง ์ ์ก ์คํจ: \(error.localizedDescription)") } } // JSON ๋ฐ์ดํฐ ์ ์ก func sendJSON<T: Encodable>(_ object: T) { guard !connectedPeers.isEmpty else { return } do { let encoder = JSONEncoder() let data = try encoder.encode(object) try session.send(data, toPeers: connectedPeers, with: .reliable) print("๐ฆ JSON ์ ์ก ์๋ฃ") } catch { print("โ JSON ์ ์ก ์คํจ: \(error.localizedDescription)") } } }
๐ฅ 3. Session ๋ธ๋ฆฌ๊ฒ์ดํธ
์ฐ๊ฒฐ ์ํ ๋ณํ์ ๋ฐ์ดํฐ ์์ ์ ์ฒ๋ฆฌํฉ๋๋ค.
MultipeerManager+SessionDelegate.swift โ ์ธ์
๋ธ๋ฆฌ๊ฒ์ดํธ
import MultipeerConnectivity extension MultipeerManager: MCSessionDelegate { // Peer ์ํ ๋ณ๊ฒฝ func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { switch state { case .connected: print("โ ์ฐ๊ฒฐ๋จ: \(peerID.displayName)") if !connectedPeers.contains(peerID) { connectedPeers.append(peerID) } case .connecting: print("๐ ์ฐ๊ฒฐ ์ค: \(peerID.displayName)") case .notConnected: print("โ ์ฐ๊ฒฐ ํด์ : \(peerID.displayName)") connectedPeers.removeAll { $0 == peerID } @unknown default: break } } // ๋ฐ์ดํฐ ์์ func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { print("๐ฅ ๋ฐ์ดํฐ ์์ from \(peerID.displayName): \(data.count) bytes") // ํ ์คํธ ๋ฉ์์ง ์๋ if let message = String(data: data, encoding: .utf8) { let formattedMessage = "\(peerID.displayName): \(message)" receivedMessages.append(formattedMessage) print(" ๐ฌ ๋ฉ์์ง: \(message)") } // ์ด๋ฏธ์ง ์๋ else if let image = UIImage(data: data) { print(" ๐ผ๏ธ ์ด๋ฏธ์ง ์์ : \(image.size)") receivedMessages.append("\(peerID.displayName): [์ด๋ฏธ์ง]") } // ๊ธฐํ ๋ฐ์ด๋๋ฆฌ ๋ฐ์ดํฐ else { print(" ๐ฆ ๋ฐ์ด๋๋ฆฌ ๋ฐ์ดํฐ") receivedMessages.append("\(peerID.displayName): [๋ฐ์ดํฐ]") } } // ํ์ผ ์์ ์์ func session( _ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress ) { print("๐ฅ ํ์ผ ์์ ์์: \(resourceName) from \(peerID.displayName)") print(" ์งํ๋ฅ : \(progress.fractionCompleted * 100)%") } // ํ์ผ ์์ ์๋ฃ func session( _ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error? ) { if let error = error { print("โ ํ์ผ ์์ ์คํจ: \(error.localizedDescription)") return } guard let localURL = localURL else { return } print("โ ํ์ผ ์์ ์๋ฃ: \(resourceName)") print(" ๊ฒฝ๋ก: \(localURL.path)") receivedMessages.append("\(peerID.displayName): [ํ์ผ] \(resourceName)") // ํ์ผ์ Documents ํด๋๋ก ๋ณต์ฌ do { let documentsURL = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask)[0] let destinationURL = documentsURL.appendingPathComponent(resourceName) // ๊ธฐ์กด ํ์ผ ์ญ์ if FileManager.default.fileExists(atPath: destinationURL.path) { try FileManager.default.removeItem(at: destinationURL) } try FileManager.default.copyItem(at: localURL, to: destinationURL) print(" ๐ ํ์ผ ์ ์ฅ: \(destinationURL.path)") } catch { print("โ ํ์ผ ์ ์ฅ ์คํจ: \(error.localizedDescription)") } } // ์คํธ๋ฆผ ์์ func session( _ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID ) { print("๐ก ์คํธ๋ฆผ ์์ : \(streamName) from \(peerID.displayName)") // ์คํธ๋ฆผ ์ฒ๋ฆฌ ๋ก์ง ๊ตฌํ } }
๐ 4. Browser & Advertiser ๋ธ๋ฆฌ๊ฒ์ดํธ
Peer ๊ฒ์๊ณผ ์ด๋ ์๋ฝ์ ์ฒ๋ฆฌํฉ๋๋ค.
MultipeerManager+ServiceDelegate.swift โ ์๋น์ค ๋ธ๋ฆฌ๊ฒ์ดํธ
import MultipeerConnectivity // Browser Delegate extension MultipeerManager: MCNearbyServiceBrowserDelegate { // Peer ๋ฐ๊ฒฌ func browser( _ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]? ) { print("๐ Peer ๋ฐ๊ฒฌ: \(peerID.displayName)") if let info = info { print(" Discovery Info: \(info)") } if !availablePeers.contains(peerID) { availablePeers.append(peerID) } } // Peer ์ฌ๋ผ์ง func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { print("โ Peer ์ฌ๋ผ์ง: \(peerID.displayName)") availablePeers.removeAll { $0 == peerID } } // Browser ์๋ฌ func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) { print("โ Browsing ์์ ์คํจ: \(error.localizedDescription)") } } // Advertiser Delegate extension MultipeerManager: MCNearbyServiceAdvertiserDelegate { // ์ด๋ ์์ func advertiser( _ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void ) { print("โ๏ธ ์ด๋ ์์ : \(peerID.displayName)") // ์๋ ์๋ฝ invitationHandler(true, session) // ๋๋ ์ฌ์ฉ์์๊ฒ ํ์ธ (์ค์ ์ฑ์์๋ UI๋ก ์ฒ๋ฆฌ) // invitationHandler(false, nil) // ๊ฑฐ์ } // Advertiser ์๋ฌ func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) { print("โ Advertising ์์ ์คํจ: \(error.localizedDescription)") } }
๐ฑ 5. SwiftUI ํตํฉ
P2P ์ฑํ ์ฑ UI๋ฅผ ๊ตฌํํฉ๋๋ค.
MultipeerChatView.swift โ P2P ์ฑํ
UI
import SwiftUI struct MultipeerChatView: View { @State private var multipeerManager = MultipeerManager() @State private var messageText = "" @State private var showingPeerList = false var body: some View { NavigationStack { VStack(spacing: 0) { // ์ฐ๊ฒฐ ์ํ connectionStatus // ๋ฉ์์ง ๋ชฉ๋ก messageList // ์ ๋ ฅ ์ฐฝ messageInput } .navigationTitle("P2P ์ฑํ ") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showingPeerList = true } label: { Image(systemName: "person.2") } } } .sheet(isPresented: $showingPeerList) { peerListSheet } .onAppear { multipeerManager.startAdvertising() multipeerManager.startBrowsing() } .onDisappear { multipeerManager.stopAdvertising() multipeerManager.stopBrowsing() } } } // ์ฐ๊ฒฐ ์ํ ํค๋ private var connectionStatus: some View { HStack { Circle() .fill(multipeerManager.connectedPeers.isEmpty ? Color.red : Color.green) .frame(width: 12, height: 12) if multipeerManager.connectedPeers.isEmpty { Text("์ฐ๊ฒฐ๋ ๊ธฐ๊ธฐ ์์") .font(.subheadline) .foregroundStyle(.secondary) } else { Text("\(multipeerManager.connectedPeers.count)๊ฐ ๊ธฐ๊ธฐ ์ฐ๊ฒฐ๋จ") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() } .padding() .background(Color(.systemGroupedBackground)) } // ๋ฉ์์ง ๋ชฉ๋ก private var messageList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(multipeerManager.receivedMessages, id: \.self) { message in Text(message) .padding(12) .background(Color(.systemGray6)) .cornerRadius(12) } } .padding() } } // ๋ฉ์์ง ์ ๋ ฅ private var messageInput: some View { HStack { TextField("๋ฉ์์ง ์ ๋ ฅ...", text: $messageText) .textFieldStyle(.roundedBorder) .onSubmit { sendMessage() } Button { sendMessage() } label: { Image(systemName: "paperplane.fill") .font(.title3) } .disabled(messageText.isEmpty || multipeerManager.connectedPeers.isEmpty) } .padding() .background(Color(.systemBackground)) } // Peer ๋ชฉ๋ก ์ํธ private var peerListSheet: some View { NavigationStack { List { Section("์ฐ๊ฒฐ๋ ๊ธฐ๊ธฐ") { if multipeerManager.connectedPeers.isEmpty { Text("์ฐ๊ฒฐ๋ ๊ธฐ๊ธฐ ์์") .foregroundStyle(.secondary) } else { ForEach(multipeerManager.connectedPeers, id: \.self) { peer in HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text(peer.displayName) } } } } Section("๊ทผ์ฒ ๊ธฐ๊ธฐ") { if multipeerManager.availablePeers.isEmpty { Text("๊ฒ์๋ ๊ธฐ๊ธฐ ์์") .foregroundStyle(.secondary) } else { ForEach(multipeerManager.availablePeers, id: \.self) { peer in Button { multipeerManager.invitePeer(peer) } label: { HStack { Image(systemName: "iphone") Text(peer.displayName) Spacer() Image(systemName: "plus.circle") } } } } } } .navigationTitle("๊ธฐ๊ธฐ ๋ชฉ๋ก") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("๋ซ๊ธฐ") { showingPeerList = false } } } } } private func sendMessage() { multipeerManager.sendMessage(messageText) multipeerManager.receivedMessages.append("๋: \(messageText)") messageText = "" } }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ๊ถํ ์ค์ : Info.plist์ NSLocalNetworkUsageDescription, NSBonjourServices ์ถ๊ฐ
- ์๋น์ค ํ์ : 15์ ์ดํ, ์๋ฌธ์/์ซ์/ํ์ดํ๋ง ์ฌ์ฉ
- ์ํธํ: encryptionPreference๋ฅผ .required๋ก ์ค์
- ๋ฐฐํฐ๋ฆฌ ํจ์จ: ์ฌ์ฉํ์ง ์์ ๋ advertiser/browser ์ค์ง
- ์ฐ๊ฒฐ ์ ํ: ์ต๋ 8๋๊น์ง๋ง ์ฐ๊ฒฐ ๊ฐ๋ฅ
๐ฏ ์ค์ ํ์ฉ
- ๋ฉํฐํ๋ ์ด์ด ๊ฒ์: ๋ก์ปฌ ๋์ ๊ฒ์
- ํ์ผ ๊ณต์ : AirDrop ์คํ์ผ ํ์ผ ์ ์ก
- ํ์ ์ฑ: ํ์ดํธ๋ณด๋, ๊ณต๋ ํธ์ง
- ๊ทผ๊ฑฐ๋ฆฌ ์ฑํ : ์ธํฐ๋ท ์์ด ์ฑํ
- IoT ์ ์ด: ๊ทผ์ฒ ์ค๋งํธ ํ ๊ธฐ๊ธฐ ์ ์ด
๐ ๋ ์์๋ณด๊ธฐ
โก๏ธ ์ฑ๋ฅ ํ:
MCSessionSendDataMode.reliable์ ์ฌ์ฉํ๋ฉด ํจํท ์ฌ์ ์ก์ผ๋ก ์์ ์ ์ด์ง๋ง ๋๋ฆฝ๋๋ค. ์ค์๊ฐ ๊ฒ์์์๋ .unreliable์ ๊ณ ๋ คํ์ธ์. Wi-Fi์ BLE ์ค ์๋์ผ๋ก ์ต์ ์ ์ ์ก ๋ฐฉ์์ ์ ํํฉ๋๋ค.