포스트

GestureClip (1) 맥과 아이폰이 서로를 찾아 텍스트를 주고받게 만들기

화웨이 노트북이랑 폰을 손으로 오므렸다 펼치면서 클립보드를 옮기는 영상을 봤다. 손을 오므리면 복사, 다른 기기에서 손을 펼치면 붙여넣기가 되는 기능이다. 애플 생태계에는 그런 게 없어서, 비슷한 걸 맥이랑 아이폰으로 직접 만들어보기로 했다.

이 글은 클로드 코드와 대화하면서 바이브 코딩으로 진행한 과정을 정리한 것이다. 카메라로 손 모양을 읽는 부분은 정확도를 잡는 데 시간이 걸릴 것 같아서, 제스처 인식부터 붙이지 않고 맥과 아이폰이 서로를 찾아서 뭔가 하나 주고받을 수 있는지부터 확인하기로 했다. 여기가 안 되면 그 다음에 뭘 붙여도 의미가 없으니까.


두 기기가 서로 찾고 연결되는 과정

애플에는 근처 기기끼리 와이파이나 블루투스로 서로를 찾아서 데이터를 주고받는 Multipeer Connectivity라는 프레임워크가 있다. 에어드롭도 내부적으로 비슷한 방식을 쓴다. 처음 써보는 프레임워크라, 코드를 보여주기 전에 각 조각이 실제로 무슨 일을 하는지부터 짚고 넘어가려 한다.

MCNearbyServiceBrowser는 같은 서비스 이름을 쓰는 근처 기기를 찾아다니는 역할이고, 찾은 기기를 세션에 초대하는 것도 이 객체가 한다. 반대편의 MCNearbyServiceAdvertiser는 “나는 이 서비스를 제공한다”고 계속 알리기만 하다가, 초대장이 오면 그걸 델리게이트한테 넘겨준다. 브라우저는 찾아다니는 쪽, 어드바타이저는 자리를 지키며 기다리는 쪽으로 역할이 나뉜다.

실제 데이터가 오가는 통로는 이 둘이 아니라 MCSession이다. 초대가 수락되면 그때부터 이 객체가 통로 역할을 한다. 세션을 만들 때 encryptionPreference.required로 주면 이후 통로는 무조건 암호화된 채로만 열리는데, 이 값은 세션을 만드는 시점에 한 번 정하면 나중에 바꿀 수 없다.

맥은 찾아다니는 쪽, 아이폰은 알리는 쪽으로 나눴다. 실제로 오간 순서를 그리면 이렇다.

맥이 startBrowsingForPeers()로 찾기를 시작하고 아이폰이 startAdvertisingPeer()로 알리기를 시작한다.

1
2
3
4
5
6
7
8
9
10
// MPCSender.swift (맥)
private lazy var browser: MCNearbyServiceBrowser = {
    let b = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
    b.delegate = self
    return b
}()

func start() {
    browser.startBrowsingForPeers()
}

맥의 브라우저가 아이폰을 찾아내는 순간 foundPeer(_:withDiscoveryInfo:)가 불린다. 여기서 invitePeer(_:to:withContext:timeout:)를 호출해서 초대를 보내는데, timeout에 10초를 줬다. 이 값을 0 이하로 주면 애플이 기본으로 30초를 쓰길래, 굳이 더 짧게 잡아서 응답이 없으면 빨리 포기하게 했다.

1
2
3
4
// MPCSender.swift (맥)
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
    browser.invitePeer(peerID, to: session, withContext: nil, timeout: 10)
}

아이폰 쪽은 advertiser(_:didReceiveInvitationFromPeer:withContext:invitationHandler:)가 불리고, 여기서 invitationHandler(true, session)을 호출하는 순간 초대가 수락된다. 두 번째 인자로 넘긴 session이 곧 아이폰이 쓰게 될 통로다.

1
2
3
4
5
6
7
8
9
10
// MPCReceiver.swift (iOS)
private lazy var advertiser: MCNearbyServiceAdvertiser = {
    let a = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: nil, serviceType: serviceType)
    a.delegate = self
    return a
}()

func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
    invitationHandler(true, session)
}

수락이 끝나면 양쪽 다 session(_:peer:didChange:)가 불리면서 상태가 .connected로 바뀐다. 여기서부터는 단순하다. connectedPeers 배열에 상대가 들어있으면 session.send(_:toPeers:with:)로 데이터를 밀어넣기만 하면 된다. .reliable을 주면 데이터가 순서대로, 빠짐없이 도착하는 걸 시스템이 보장해준다.

1
2
3
4
5
6
// MPCSender.swift (맥)
func send(text: String) {
    // 생략
    let data = Data(text.utf8)
    try session.send(data, toPeers: session.connectedPeers, with: .reliable)
}

받는 쪽은 session(_:didReceive:fromPeer:)로 데이터가 도착한 걸 안다.

1
2
3
4
5
6
7
// MPCReceiver.swift (iOS)
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
    let text = String(data: data, encoding: .utf8) ?? ""
    DispatchQueue.main.async {
        self.receivedText = text
    }
}

실제로 되는지 확인

맥 앱에는 텍스트 입력창이랑 전송 버튼만 하나, 아이폰 앱에는 상태 문구랑 받은 텍스트를 보여주는 화면만 하나 만들었다. 두 앱을 같은 와이파이에 켜두면 몇 초 안에 맥 화면에 “아이폰 이름에 연결됨”이 뜨고, 텍스트를 입력하고 전송을 누르면 바로 아이폰 화면에 그 글자가 나타난다.

여기까지 확인하고 나니 다음에 뭘 해야 할지가 명확해졌다. 맥에서 웹캠으로 손 오므리는 동작을 잡아내는 부분, 그리고 그 동작이 감지됐을 때 지금은 텍스트 입력창에 직접 쓰고 있는 문구 대신 실제 클립보드 내용을 읽어서 보내는 부분이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.