dotgimon 제작기 09. 근거리 통신 네이티브 모듈 구현
dotgimon은 Expo와 React Native로 만드는 가상 펫 게임이다. QR로 대전 정보를 주고받는 기능은 있었지만, 두 기기가 가까이 있을 때 서로를 찾고 연결하는 기능은 없었다. 대전과 합체 규칙을 연결하기 전에 기기를 찾고 문자열을 주고받을 네이티브 모듈부터 만들었다.
날짜는 작업 순서에 맞춰 배치했다. 실제 변경은 9월 30일에 저장소에 반영했다. 당시 모듈 이름은
pixel-pet-nearby였다.
기종이 달라도 연결되는 구성이 필요했다
계정이나 서버 없이 양쪽 몬스터 정보를 교환하고 같은 결과를 확인하는 기능이었다. 인터넷 없이 가까운 두 기기가 연결되면 됐다. OS의 근거리 발견 API와 권한 설정이 필요해서 로컬 Expo 네이티브 모듈을 만들었다.
iOS의 MultipeerConnectivity와 Android의 Google Nearby Connections를 각각 쓰는 구성을 검토했다. iOS에 별도 SDK를 붙이지 않아도 되지만 두 구현 사이의 연결은 막힌다. 실제 코드는 양쪽 모두 Google Nearby Connections를 쓰도록 작성했고, 문서에도 MultipeerConnectivity와 혼용하지 않는다고 적었다. 선택 이유를 따로 기록하지는 않았지만 기종 간 연결을 고려한 구조였다.
Google Nearby Connections는 인터넷 연결 없이 주변 기기를 발견하고 데이터를 주고받는 API다. iOS에도 Swift 패키지가 있어서 같은 통신 방식을 쓸 수 있었다.
접촉 감지나 거리 측정은 넣지 않았다. AirDrop 같은 시스템 화면도 쓰지 않고, 양쪽 앱을 열어 근거리 발견을 시작하게 했다. 가까운 다른 기기가 잡힐 수 있으므로 연결 전에 양쪽에 뜬 인증 숫자를 확인하는 단계를 뒀다.
게임 규칙은 JS에 두고 모듈은 연결만 맡겼다
네이티브에는 기기 찾기, 연결, 인증 확인, 문자열 전송, 종료만 넣었다. 두 플랫폼의 인터페이스를 맞추면 JS에서 OS에 따라 게임 규칙을 나눌 필요가 없었다.
// src/nearby/native.ts
export interface NearbyEvent { type: 'found' | 'lost' | 'verification' | 'connected' | 'disconnected' | 'message' | 'error'; peerId: string; name?: string; code?: string; data?: string; message?: string }
declare class NearbyModule extends NativeModule<{ onNearby: (event: NearbyEvent) => void }> {
start(name: string, advertise: boolean): Promise<void>
connect(peerId: string): Promise<void>
verify(peerId: string, accept: boolean): Promise<void>
send(peerId: string, data: string): Promise<void>
stop(): Promise<void>
}
export const nearby = requireOptionalNativeModule<NearbyModule>('PixelPetNearby')함수는 다섯 개, 이벤트는 onNearby 하나였다. 그 이벤트 안에서 발견·이탈·인증·연결·해제·메시지·오류의 일곱 종류를 구분했다. requireOptionalNativeModule은 모듈이 없으면 null을 돌려주므로 Expo Go에서는 앱을 종료시키는 대신 통신 미지원 안내를 보여줬다.
Android는 한 상대와만 연결했다
Android 모듈에는 com.google.android.gms:play-services-nearby:19.3.0을 추가했다. start의 advertise 값에 따라 기다리기와 찾기 중 한 역할을 시작하도록 작성했다.
// modules/pixel-pet-nearby/android/.../PixelPetNearbyModule.kt (발췌)
AsyncFunction("start") { displayName: String, advertise: Boolean, promise: Promise ->
stop(); running = true; name = displayName.take(48)
val task = if (advertise) client.startAdvertising(name, service, lifecycle,
AdvertisingOptions.Builder().setStrategy(Strategy.P2P_POINT_TO_POINT).build())
else client.startDiscovery(service, discovery,
DiscoveryOptions.Builder().setStrategy(Strategy.P2P_POINT_TO_POINT).build())
task.addOnSuccessListener { promise.resolve(null) }
.addOnFailureListener { running = false; promise.reject("NEARBY_START", it.message, it) }
}.runOnQueue(Queues.MAIN)P2P_POINT_TO_POINT는 동시에 한 상대와 연결하는 1:1 전략이다. 두 대가 대전하는 기능에 맞았고, 표시 이름은 48자까지만 받았다.
연결 요청이 오면 ConnectionLifecycleCallback의 onConnectionInitiated에서 상대를 확인했다. 이미 상대가 정해져 있는데 다른 기기가 요청하면 바로 거절했다.
override fun onConnectionInitiated(id: String, info: ConnectionInfo) {
if (!running || (activePeer != null && activePeer != id)) { client.rejectConnection(id); return }
activePeer = id
event("verification", id, mapOf("name" to info.endpointName, "code" to info.authenticationDigits))
}Nearby가 주는 authenticationDigits를 이벤트로 JS에 넘겼다. 양쪽 사용자가 같은 숫자인지 확인한 뒤 verify(peerId, true)를 호출하면 acceptConnection에 수신 콜백을 연결했다. Google 문서에서도 인증 토큰을 비교해 연결 상대를 확인하는 절차를 안내한다.
수신은 현재 상대가 보낸 바이트 페이로드만 받았다. 16,384바이트를 넘는 메시지는 버리고, 송신에도 같은 상한을 적용했다. 연결이 성립하면 광고와 검색을 모두 멈췄다. JS에서 호출하는 AsyncFunction은 모두 runOnQueue(Queues.MAIN)으로 메인 큐에서 실행하도록 지정했다.
iOS는 인증 콜백을 보관했다가 답했다
iOS 모듈은 Google Nearby의 ConnectionManager, Advertiser, Discoverer 델리게이트를 구현했다. Android와 같은 이벤트 이름과 필드를 사용하고, 서비스 ID도 com.pixelpet.nearby.v1로 맞췄다. 두 플랫폼이 같은 발견 채널을 사용하게 한 설정이었다.
인증 숫자가 도착할 때 함께 오는 verificationHandler는 사용자의 확인을 기다려야 했다. 이 콜백을 verifications 딕셔너리에 보관했다가 JS의 verify 호출에 답했다. 모듈이 멈추면 대기 중인 콜백을 모두 false로 마무리했다.
광고하는 쪽은 연결 요청을 connectionRequestHandler(true)로 받아 둔 뒤 인증 숫자 확인 단계에서 걸렀다. 문자열 전송에 필요 없는 스트림과 리소스 요청은 token.cancel()로 취소했다.
직접 고친 빌드 설정을 플러그인으로 옮겼다
Android 권한은 매니페스트에 선언하고 requestNearbyPermissions에서 런타임 요청을 했다. 당시 코드는 API 31 이상에서 Bluetooth 권한 세 종류를 요청했다. API 33 이상에는 NEARBY_WIFI_DEVICES, 33 미만에는 위치 권한을 추가했다. 필요한 권한 중 하나라도 거절되면 안내 오류를 돌려줬다.
BLUETOOTH_SCAN과 NEARBY_WIFI_DEVICES에는 neverForLocation을 달았다. iOS에는 Bluetooth와 로컬 네트워크 사용 설명, Bonjour 서비스 이름을 설정했다. 여기서 구현한 권한 처리는 당시 코드의 범위다. 필요한 권한은 Android 버전과 iOS 전송 매체에 따라 달라진다.
prebuild --clean은 생성된 네이티브 디렉터리를 지우고 다시 만든다. 옵션 없는 prebuild는 기존 파일에 변경을 적용하지만, 재생성할 때도 설정을 유지하려고 Config Plugin에 넣었다.
// plugins/withPixelPetNearby.js (발췌)
const hash = crypto.createHash('sha256').update('com.pixelpet.nearby.v1').digest('hex').slice(0, 12)
config.modResults.NSBonjourServices = [...new Set([...(config.modResults.NSBonjourServices ?? []), `_${hash}._tcp`])]플러그인은 서비스 ID의 SHA-256을 16진수 문자열로 바꾼 뒤 앞 12자리를 Bonjour 서비스 이름에 썼다. Set으로 중복 등록을 막았고, 서비스 ID를 바꾸면 이름도 같은 계산을 따르게 했다.
Podfile에는 post_integrate 훅을 붙여 plugins/nearby-spm.rb를 불렀다. 마커 주석이 이미 있으면 훅을 다시 넣지 않았다. 생성되는 ios/ 밖에 구현을 두어 네이티브 프로젝트를 다시 만들어도 재사용했다.
Swift 빌드 플래그를 덮어써서 개발 런처가 빠졌다
iOS의 로컬 Expo 모듈은 CocoaPods 기반인데 Nearby는 Swift Package Manager 패키지였다. nearby-spm.rb에서 Pods 프로젝트의 모듈 타깃에 패키지 의존성을 연결했다. 버전 태그 대신 특정 revision으로 고정했지만 그 선택 이유는 따로 남기지 않았다.
정적 pod에 이미 SDK 객체가 들어 있으므로 앱 타깃에서는 NearbyConnections를 다시 링크하지 않게 했다. 중복 링크로 같은 심볼이 겹치는 것을 피하려고 프레임워크 단계와 의존성 목록에서 해당 항목을 지웠다.
Expo 57의 최소 iOS 버전에 맞춰 낮은 배포 타깃은 16.4로 올렸다. CocoaPods가 pod별로 BUILT_PRODUCTS_DIR를 바꾸는 문제에는 Swift 탐색 경로와 NearbyCoreAdapter.modulemap 경로를 명시했다.
처음에는 SWIFT_INCLUDE_PATHS, FRAMEWORK_SEARCH_PATHS, OTHER_SWIFT_FLAGS를 통째로 바꿨다. 그 과정에서 Expo autolinking이 넣은 -D EXPO_CONFIGURATION_DEBUG가 사라졌다. 개발 런처 코드가 컴파일에서 빠지고 번들 주소가 null이 되는 문제를 만나, 기존 값 뒤에 설정을 추가하는 헬퍼로 고쳤다.
# plugins/nearby-spm.rb (설정 이어 붙이기)
def pixel_pet_append_setting(config, key, values)
current = config.build_settings[key]
current = current.nil? ? ['$(inherited)'] : Array(current).flat_map { |v| v.split(' ') }
return if values.all? { |v| current.include?(v) }
config.build_settings[key] = (current + values).join(' ')
end기존 설정이 문자열인지 배열인지 맞춘 뒤 필요한 값만 추가했다. 이미 모두 들어 있으면 그대로 두었다. 패키지 경로를 붙이는 작업이 개발 런처 설정까지 지우고 있었다.
네이티브 코드를 추가한 뒤에는 expo-dev-client를 포함한 development build를 개발 기준으로 정했다. 실행 경로는 pnpm ios·pnpm android로 앱을 빌드하고 pnpm start:dev로 연결하는 방식으로 정리했다. Expo Go에는 임의의 로컬 네이티브 모듈을 추가할 수 없어 통신 미지원 안내까지만 확인하는 구조였다.
구현 당시 문서에는 테스트 138개, TypeScript 검사, 양 플랫폼 JS export, iOS Simulator 앱 빌드와 Android 모듈 Kotlin 컴파일을 통과했다고 남겼다. 실기기끼리 서로를 찾고 연결한 결과는 없었다. 두 플랫폼의 인터페이스와 빌드는 맞췄지만 실제 무선 연결까지 검증한 상태는 아니었다.