내 주변 송금이 블루투스로 만들어졌다고?

내 주변 송금이 블루투스로 만들어졌다고?

시작하며

안녕하세요! 카카오페이 송금 관련 기능들을 개발하는 머니클라이언트파티에서 Android 개발을 하고 있는 해로, iOS 개발을 하고 있는 냅스터입니다.

카카오페이 하면 가장 먼저 떠오르는 기능은 역시 송금이죠? 저희는 이번에 새로운 형태의 송금 서비스인 내 주변 송금이라는 서비스를 개발하게 되었습니다. 기술적으로나, 기획적으로나 꽤나 새로운 시도였던 만큼, 다사다난한 프로젝트이기도 했는데요. 내 주변 송금 서비스란 무엇이고, 저희가 해당 서비스를 어떻게 개발하고, 어떤 기술적인 트러블이 있었는지 소개해 드리고자 글을 작성하게 되었습니다.

그럼 우선, 카카오페이의 신규 기능이자 본 글의 주제인 내 주변 송금 서비스에 대해 알아봅시다!

iOS에는 ‘AirDrop’ 있고, 카카오페이에는 ‘내 주변 송금’ 있다!

내 주변 송금 flow
내 주변 송금 flow

내 주변 송금 서비스사용자 주변에 있는 카카오페이 사용자에게 송금할 수 있는 서비스입니다. 여러분, 모임에서 만난 잘 모르는 사람과 송금해야 하는데, 그렇다고 카카오톡 친구로 추가하기는 부담스러웠던 경험 있으시죠? 내 주변 송금 서비스를 이용하면 꼭 카카오톡 친구가 아니어도 주변에 있기만 하면 송금할 수 있습니다. 즉, 지인이 아닌 사이에도 부담 없이 송금할 수 있기에 중고 거래, 일회성 모임 등에 특화되어 있는 기능입니다!

내 주변 송금BLE (Bluetooth Low Energy)라는 기술을 기반으로 만들어졌는데요, 그럼 지금부터 BLE 기술이 무엇인지? 내 주변 송금 서비스가 어떻게 만들어졌는지? 천천히 알아보도록 하겠습니다.

BLE (Bluetooth Low Energy) 란?

여러분 모두 블루투스라는 기술은 한 번쯤은 들어보셨을거라 생각됩니다. 그만큼 블루투스는 우리의 생활에 빠질 수 없는 기술이 되었는데요, 내 주변 송금 서비스의 근간이 되는 Bluetooth Low Energy (이하 BLE)는 우리가 알고 있던 블루투스와 동작 형태가 조금 다릅니다.

bluetooth
bluetooth

블루투스 4.0 표준과 함께 등장한 BLE는 기존 블루투스와 다르게 단방향 통신으로 데이터를 주고 받는 형태입니다. 따라서 블루투스 4.0 이후로 블루투스는 크게 두 가지로 나뉘게 되는데, 통신 형태가 양방향이냐, 단방향이냐에 따라 각각 Bluetooth Classic, Bluetooth Low Energy로 나뉘게 됩니다.

또한, Bluetooth Classic은 흔히 알려진 페어링을 통해 양방향으로 데이터를 주고받는데, 이 과정에서 배터리 소모가 심하다는 단점이 있습니다. 반면 BLE는 페어링 과정을 생략한 단방향 통신 기법이기 때문에, Bluetooth Low Energy라는 이름에서도 알 수 있듯 저전력으로 동작한다는 장점이 있습니다. 그렇다면, 페어링 없이 데이터를 단방향으로 주고받을 수 있는 원리는 무엇일까요?

Advertising
Advertising

BLE가 단방향 통신으로 데이터를 주고받을 수 있는 것은 Advertise Mode(= Broadcast Mode)가 존재하기 때문입니다. Advertise Mode란, 데이터를 송출할 때 특정 디바이스를 겨냥하고 데이터를 보내는 것이 아닌, 주변 디바이스가 데이터를 받는지에 관계없이 데이터를 사방팔방 송출하는 것입니다. 이 때 데이터를 사방팔방 송출하는 주체Advertiser라고 하고, 송출하는 데이터를 Packet이라고 합니다.

그리고, Advertiser가 송출하고 있는 Packet을 수신하기 위해 주기적으로 신호 탐색을 하는 주체Scanner(= Observer) 라고 합니다. Scanner는 주기적으로 주변에 송출되고 있는 Packet을 탐색하여, 원하는 데이터만을 필터링하여 적절히 활용할 수 있습니다. 내 주변 송금 서비스 또한 Advertise Mode를 기반으로 만들어진 서비스인데요, 이를 어떻게 활용하여 구현되었는지 함께 살펴봅시다.

내 주변 송금 동작 원리

자세한 설명에 앞서 우선 전반적인 그림을 소개해 드리겠습니다. 카카오페이에 가입한 사용자라면 고유한 송금 코드를 발급받게 되는데요. 송금 코드는 사용자 각각을 식별할 수 있는 코드이기 때문에, 카카오톡 친구 추가 없이 코드 송금 기능을 통해 특정 사용자에게 송금을 할 수도 있습니다. 내 주변 송금 서비스는 송금 코드를 활용한 기능입니다. 카카오페이 사용자 각각이 Advertiser가 되어, 각자의 송금 코드를 Packet에 담아 Advertise 하는 방식을 채택하였습니다. 따라서, 카카오페이 앱을 이용하는 중에는 송금 코드가 담긴 Packet을 주기적으로 송출하게 되고, 언제든 송금을 받을 수 있는 상태가 됩니다.

원리
원리

주변인에게 송금하려는 사용자가 내 주변 송금 서비스 화면에 진입하게 되면 Advertiser 역할과 더불어 Scanner 역할도 가지게 되어 주변 사용자들을 주기적으로 탐색하기 시작합니다. 이때 Advertising되고 있는 송금 코드 Packet이 발견되면, 이를 사용자에게 보여주고 송금할 수 있는 프로세스를 제공합니다. 지금부턴 이 과정들을 조금 더 자세하게 설명해보고자 합니다.

사용자 기기가 Advertising하는 Packet 구조

BLE에서 정의하는 Advertising Packet 프로토콜에 의거하면, 데이터를 담을 수 있는 영역은 여러 가지가 있습니다. 저희는 그중 iOS와 Android 기기가 비교적 쉽게 상호 데이터를 핸들링할 수 있는 영역인 Service UUID라는 영역을 활용해 보았습니다. Service UUID 영역은 16Bytes 사이즈 제한이 있고, 그 안에서 서비스에 필요한 데이터를 구성하면 됩니다. 따라서 저희는 서비스 식별자, 송금 코드 등 송금에 필요한 필수 정보들을 꾹꾹 눌러 담아 Advertising Packet으로 송출하도록 하였습니다. 이렇게 저희만의 프로토콜을 정의하면, 플랫폼(iOS & Android)과 상관없이 데이터 교환이 가능하겠죠?

내 주변 송금에서는 아래와 같은 16 Bytes의 패킷 구조를 정의하였습니다. 한글은 2Bytes이므로 두 글자를 차지하는 식으로 표현해 보았어요.

패킷 구조
패킷 구조

그리고 해당 Packet은 만천하에 송출되는 정보이므로, 자칫 악용될 위험이 있기 때문에 저희가 정의한 일련의 난독화 과정을 거쳐 송출하게 됩니다. 개인정보를 담는 것은 지양해야겠죠? 내 주변 송금에서는 사용자의 이름을 모두 담지 않고 앞 글자와 뒷글자, 그리고 글자 수를 담아 마스킹하여 표시할 수 있도록 정의하였습니다!

주변 사용자 Scanning 및 송금 동작

주변에 있는 사람에게 송금을 하기 위해서는 송금 대상이 송출하고 있는 Packet을 탐지해야 합니다. 따라서 내 주변 송금 화면에 진입하게 되면, Scanner로서 동작을 수행하게 됩니다. Scanning을 시작하면 정해진 주기에 따라 주변에 감지되는 Packet을 스캔하게 되고, 내 주변 송금을 위한 Packet만을 필터링하여 UI에 노출하게 됩니다. Packet에 담긴 사용자 이름의 앞 글자, 뒷글자, 이름 글자 수 등을 활용하여 마스킹 된 이름을 표시하고, 사용자를 클릭하면 코드 송금 화면으로 이동할 수 있도록 Packet 안에 있는 송금 코드를 활용하였습니다.

내 주변 송금은 이러한 원리로 개발되었는데요, 하드웨어와 직결된 기능이기도 하고 iOS와 Android 두 플랫폼 간 원활한 통신이 이루어져야 하다 보니 여러 난항을 겪기도 했습니다. 지금부턴 내 주변 송금을 개발하면서 경험한 트러블 슈팅 관련한 이야기를 해보고자 합니다.

트러블 슈팅

Android BLE Scan 동작 이슈

Android는 자체적으로 제공해 주는 BLE 모듈을 사용하여 BLE Advertising/Scanning 기능을 개발할 수 있습니다. 그중 Scanning 동작을 표현한 간단한 코드 스니펫을 함께 보겠습니다.

val bluetoothManager =
    context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager

val leScanner = bluetoothManager.adapter.bluetoothLeScanner

val setting = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .setReportDelay(reportDelay)
    .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
    .build()

val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult?) {
        super.onScanResult(callbackType, result)
    }

    override fun onBatchScanResults(results: MutableList<ScanResult>?) {
        super.onBatchScanResults(results)
    }

    override fun onScanFailed(errorCode: Int) {
        super.onScanFailed(errorCode)
    }
}

leScanner?.startScan(listOf(scanDeviceNameFilter, scanUUIDFilter), setting, scanCallback)

Scanning 설정 관련 Builder에서, Scan 주기를 설정할 수 있는 setReportDelay라는 API를 제공해줍니다.

val setting = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .setReportDelay(5_000L)
    .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
    .build()

위와 같이 설정하게 되면, 5초 동안 주변에 감지된 Packet을 onBatchScanResults 콜백을 통해 한 번에 내려주게 됩니다. 해당 결과값을 가공 및 활용하여 화면에 다른 사용자들을 그려주는 등 동작을 구현할 수 있습니다. 내 주변 송금 서비스의 경우 안정성을 고려하여 5초에 한 번 주변 사용자 목록이 갱신되도록 스펙을 정의하였는데요, 여기서 문제가 발생하게 됩니다.

QA: ‘일부 기기에서 주변 사용자 목록이 끊임없이 바뀌어요!’

이게 무슨 일인가 하고 봤더니, 대부분 기기에서는 5초에 한 번 Scan 결과가 갱신되는 식으로 정상 동작하고 일부 기기에서는 무한정 결과가 갱신되고 있었습니다. 비슷한 이슈 케이스가 있는지 구글링해 본 결과 다음과 같은 API를 알게 되었습니다.

BluetoothAdapterisOffloadedScanBatchingSupported 라는 녀석은 디바이스가 ‘Scan Batching’을 지원하는 여부를 판단해 준다고 합니다. 즉, Report Delay 설정을 통해 Batch Scan Result를 받을 수 있는 것은 하드웨어가 지원해 줘야만 동작하는 기능이었습니다. (여기서 하드웨어는 블루투스 칩셋을 의미합니다)

따라서 isOffloadedScanBatchingSupported를 통해 칩셋이 Scan Batching을 지원하는지 체크하고, 지원하지 않는다면 Coroutine 등을 활용하여 Batching 동작을 구현해 주면 됩니다.

저희는 동작의 일관성을 위해 isOffloadedScanBatchingSupported 값을 판단하지 않고, 모든 기기에서 Coroutine으로 Scan Result를 직접 Buffering하여 Batching 처리하는 방식을 채택하였습니다.

coroutineScope.launch(Dispatchers.IO) {
    var bufferedResult: List<PayBleScanResult> = listOf()

    startMonitoring(
        reportDelay = 5_000L,
        onBatchScanResults = { result, _ ->
            bufferedResult = result
        }
    )

    while (isActive) {
        delay(5_000L)
        viewModel.updateMonitoringResult(bufferedResult)
    }
}

EUC-KR Data Encoding 이슈

본문에서 설명드렸던 것처럼, 저희는 16Bytes의 한정된 용량 내에서 내 주변 송금에 필요한 데이터를 주고받을 수 있는 프로토콜을 만들었습니다.

String 값을 BLE 패킷의 Service UUID에 싣기 위해서는 Byte Array 형식으로 변경을 해줘야 합니다. 이때 String을 어떤 Encoding 방식으로 Encode하냐에 따라 Byte 크기가 달라지게 됩니다.

하지만, 개발을 진행하고 QA 시작 직전 문제가 발생합니다. UUID 데이터를 주고받는 모듈에서 예상하지 못한 방식으로 데이터를 주고받고 있었습니다. 분명 한글은 2Bytes라고 철석같이 믿고 있었습니다. 그러나, UTF-8은 가변 Encoding 방식이라 영문은 1Byte, 한글은 3bytes를 차지합니다. 예상했던 Byte 수보다 초과하여 데이터가 일부 유실되었던 것이었습니다. 한글을 Encoding 할 때 2Bytes로 사용되는 EUC-KR 형식으로 Encoding 해야 저희가 원하는 16bytes 프로토콜에 맞추어 데이터를 주고받을 수 있었습니다.

Kotlin에서는 자체적으로 제공하는 String을 ByteArray로 변환하는 API를 사용하면 EUC-KR 방식으로 자동 인코딩되어 문제가 없었습니다.

val str16 = this.padEnd(16, '0')
val byteArray = str16.toByteArray()

Swift에서 ByteArray를 변환하는 방법은 다음과 같습니다.

var byteArray1: [UInt8] = Array(string.utf8)
// or
var byteArray2 = string.utf8CString
// UniCode의 null Code 0이 포함되어있다.

하지만 Swift String.Encoding Type Properties에는 EUC-KR가 없기 때문에 rawValue를 사용해 줘야 하는데요. Swift에서 EUC-KR를 사용하려면 다음과 같이 CFStringConvertEncodingToNSStringEncoding(0x0940) 를 사용해야합니다.

    func encodeEUCKR(_ string: String) -> [Int8] {
        let encodingEUCKR = CFStringConvertEncodingToNSStringEncoding(0x0940)
        let size = string.lengthOfBytes(using: String.Encoding(rawValue: encodingEUCKR))
        var byteArray = [CChar](repeating: 0, count: size)
        _ = string.getCString(&byteArray, maxLength: size, encoding: String.Encoding(rawValue: encodingEUCKR))
        return byteArray
    }

이렇게 하면 String을 EUC-KR Encoding 방식의 Byte Array로 Encode 할 수 있습니다.

그런데, 데이터를 주고받는 객체를 UUID 객체에 넣어서 보내야 하는데, EUC-KR은 Int8이었고 UUID객체가 요구하는 데이터 타입은 UInt8이어서 인코딩 및 디코딩 과정에서 데이터 변형이 있지 않을까 하는 생각을 했습니다. 그래서 처음엔 UUID가 아닌 객체를 새롭게 만들어서 넘겨줘야 한다고 생각을 했습니다.

/// Represents UUID strings, which can be used to uniquely identify types, interfaces, and other items.
@available(macOS 10.8, iOS 6.0, *)
public struct UUID : ReferenceConvertible, Hashable, Equatable, CustomStringConvertible, Sendable {

    public typealias ReferenceType = NSUUID

    public var uuid: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) { get }
}

객체를 새롭게 만드는 것도 좋지만, ‘좀 더 나은 방법이 있지 않을까?’ 고민이 들었습니다. 생각해 보니 UInt8와 Int8는 데이터 크기는 동일하지만, 단순하게 Unsigned/Signed에 따른 값의 차이가 있다는 걸 깨달았습니다. UInt8로 Mapping하여 초기화하는 방식으로 두 데이터 간의 변환을 훨씬 쉽게 할 수 있었습니다.

var byteArray = encodeEUCKR(string)
byteArray.map(UInt8.init)

마치며

많은 클라이언트 개발자들은 매년 Google I/O나 WWDC 등과 같은 키노트에서 발표하는 새로운 기술들을 어떻게 사용하면 좋을지 늘 고민하고 마음속에 품고 있습니다. 늘 마음속에 품고 있던 기술을 이용해 아이디어를 내고 그 아이디어를 “내 주변 송금”으로 실제 서비스화해본 것은 값진 경험이었습니다. 엄밀히 따지면 하드웨어 기능인 블루투스와 접목한 아이디어다 보니 예상 못 한 변수들도 많았고 안정성에 대해 여러 고민이 들기도 했지만, 그런 이슈들을 털어내는 과정 자체가 재미있었기 때문에 스트레스를 전혀 받지 않고 즐겁게 개발할 수 있었던 것 같습니다.

놀랍게도 WWDC 2023에서도 새롭게 선보인 ’NameDrop‘이라는 기능도 내부적으로는 블루투스도 사용하는 것으로 보이는데요. 오래된 기술, 새로운 기술에 상관없이 신박한 아이디어를 실제화할 수 있다는 점이 재밌는 포인트였고, 카카오페이의 내 주변 송금 서비스 개발도 그러한 점에서 좋은 시도였다고 생각합니다. 애플, 네임드랍, 카카오페이, 내 주변 송금 렛츠고

카카오페이에서 기존 서비스와 기술이 새로운 아이디어와 접목하여 발전된 서비스로는 ‘내 주변 송금’이 첫 사례가 되었는데요, 앞으로 나오게 될 재미있는 새로운 기능들도 기대해 주세요! 이 글을 읽고 계신 여러분들도, 다양한 기술들과 결합한 무궁무진한 아이디어를 펼쳐보세요! 분명 재미있는 경험이 될 겁니다 :)

참고 자료

swift-corelibs-foundation/CoreFoundation/String.subproj/CFStringEncodingExt.h

Unicode와 UTF-8 간단히 이해하기

iOS 에서 한글 EUC-KR Encoding 문제

How to use NameDrop on iPhone | Apple Support

iOS 17 makes iPhone more personal and intuitive

NameDrop iOS 17: All You Need to Know About the New Apple Feature

Bluetooth Low Energy

setReportDelay

isOffloadedScanBatchingSupported

이미지 출처

Bluetooth Classic vs Bluetooth Low Energy

haero.ro
haero.ro

카카오페이의 송금, 머니 서비스를 개발하는 머니클라이언트파티 Android 개발자 해로라고 합니다!

napster.x
napster.x

카카오페이에서 머니서비스 iOS 를 담당하고 있는 Napster 입니다. 반가워요.