블루투스로 거리를 잰다고요? (사실 거리는 안 잽니다)

만들어보고 싶은 게 하나 있었습니다. 결론부터 말하면 이 구상 자체는 제품이 되지 못했는데, 그 이야기는 마지막에 하겠습니다.

싱글 남녀가 모여서 대화하는 소셜링 파티를 생각해보세요. 시작 전에 참가자들이 각자 앱에 이름과 정보를 입력하면, 그 사람의 블루투스 식별자가 함께 서버에 등록됩니다. 파티가 중반쯤 흐르면 다들 호감 가는 상대에게 조용히 투표를 하고요.

그리고 파티는 계속됩니다. 아무 일도 없었다는 듯이요.

그런데 그때부터, 나한테 투표한 사람이 내 근처로 오면 내 폰이 웁니다. 삐— 삐— 삐비비비비. 금속탐지기가 뭔가를 찾았을 때 내는 그 소리요. 주머니 속에서 진동으로만 알려줘도 되고요.

중요한 건 누구인지는 끝까지 안 알려준다는 겁니다. 이건 못 만들어서가 아니라 일부러 그렇게 두고 싶었어요.

제가 그리고 싶었던 장면은 이런 거였습니다.

못 느끼고 있었는데, 아까부터 주머니에서 진동이 울리고 있다. 이 테이블의 누가 나를 좋아하는 걸까?

이름이 뜨면 그냥 알림입니다. 확인하고 끝이에요. 이름이 안 뜨면 그때부터 고개를 들어 사람을 보게 됩니다. 기계가 알려주는 건 "가까이 있다" 하나뿐이고, 나머지는 전부 사람 몫으로 남겨두는 거죠.

심장이 쿵쾅거리는 걸 기계가 대신 해주는 게 아니라, 심장이 쿵쾅거릴 이유만 슬쩍 놓아주는 쪽에 가깝습니다. (이 장면을 떠올렸을 때는 진짜 다 만든 줄 알았습니다. 소리 파일이랑 진동 패턴만 구하면 될 줄 알았어요.)

여기서 제가 필요한 게 뭔지 보세요. "저 사람과 나 사이가 3.2미터"가 아닙니다. "가까이 왔나, 아직인가" 그거 하나예요.

그래서 Flutter 플러그인을 하나 짜기 시작했는데, 아주 이른 단계에서 멈추게 됩니다. 폰 두 대를 책상에 올려두고 손도 안 대고 값을 찍어보면, 그 값이 가만히 있지를 않거든요. 거리는 1밀리미터도 안 변했는데 스캔할 때마다 숫자가 위아래로 펄럭입니다.

가만히 있는 두 기기도 이런데, 사람이 북적이는 파티장에서는 어떻겠어요.

"아 이거 그냥 숫자 받아다가 화면에 뿌리면 되는 거 아니었나?" 아니었습니다. 이 글은 그 펄럭이는 숫자를 어떻게 믿을 만한 "가까움"으로 바꾸는지에 대한 이야기입니다.

그래서 RSSI가 뭔데요

먼저 그 "숫자"의 정체부터요.

블루투스 기기는 주변에 자기 존재를 알리는 신호를 계속 뿌리고, 받는 쪽에서는 "얼마나 세게 들렸는지"를 잴 수 있습니다. 그 값이 RSSI(Received Signal Strength Indicator, 수신 신호 세기)이고, 단위는 dBm을 씁니다.

처음 보는 분들이 한 번씩 걸리는 지점이 있어요. 이 값이 음수라는 겁니다. -45, -60, -80 이런 식으로 나오거든요. 음수라고 하니까 "약하다"는 느낌이 먼저 오는데, 중요한 건 부호가 아니라 크기 비교예요.

  • -45 dBm → 신호가 세게 들린다 → 가깝다
  • -80 dBm → 신호가 희미하게 들린다 → 멀다

0에 가까울수록 가깝다입니다. -45-80보다 훨씬 가까운 상태예요. (저는 이걸 헷갈릴 때마다 "빚이 45만 원인 사람이 80만 원인 사람보다 형편이 낫다"고 생각하고 넘어갑니다. 유치한데 잘 안 헷갈려요.)

그리고 아까 그 펄럭임. 이건 제 기기가 고장 나서가 아니라 무선 신호의 기본 성질입니다. 주변 기기들이 같은 주파수 대역을 함께 쓰면서 생기는 간섭, 사람 몸이나 가방 같은 물리적 장애물, 그리고 신호가 벽과 바닥에 튕겨서 여러 경로로 도착하는 다중경로 전파(multipath propagation). 전파는 직진만 하는 게 아니라 사방으로 튕겨 다니면서 자기들끼리 겹치기도 하고 상쇄되기도 하거든요.

그러니까 원시 RSSI는 방향은 대충 맞지만 순간값은 못 믿는 숫자입니다. 이걸 그대로 UI에 연결하면 "가까움" 배지가 1초에 몇 번씩 켜졌다 꺼졌다 합니다. 사용자는 앱이 고장 났다고 생각하겠죠. (그리고 그게 맞습니다. 고장 난 거예요.)

네 단계짜리 정수기

그래서 제 플러그인은 원시 RSSI를 바로 쓰지 않고 파이프라인에 한 번 통과시킵니다. 지저분한 물을 그대로 마시지 않고 정수기에 통과시키는 것과 같은 발상이에요.

[Raw RSSI Stream] ──> [EMA Smoothing] ──> [Hysteresis Thresholds] ──> [Intensity Mapping (0..1)]

입구에는 펄럭이는 dBm이 들어가고, 출구에서는 안정적인 범주형 레벨(가깝다 / 아주 가깝다 / 멀다)과 0.0에서 1.0 사이로 정규화된 강도값이 나옵니다. UI는 이 두 개만 보면 돼요.

첫 단계는 별 게 없습니다. BLE 스캔 콜백이 돌 때마다 타깃별로 RSSI를 읽어서 받아 적기만 하거든요. 재밌는 건 그다음부터입니다.

EMA — "최근 걸 더 크게 쳐주는 평균"

펄럭이는 값을 부드럽게 만드는 가장 쉬운 방법은 평균입니다. 그런데 "최근 20개의 평균" 같은 걸 쓰면 문제가 생겨요. 체중계로 비유해볼게요. 매일 아침 몸무게는 전날 뭘 먹었는지에 따라 오르락내리락하니까, 사람들은 최근 며칠 평균을 봅니다. 그런데 평균 낼 기간을 30일로 잡으면 숫자는 아주 매끄러워지는 대신, 오늘부터 진짜로 살이 빠지기 시작해도 그래프가 한참 꿈쩍도 안 합니다. 30일 전의 나와 오늘의 나를 똑같은 무게로 취급하고 있으니까요.

EMA(Exponential Moving Average, 지수이동평균)는 이 문제를 이렇게 풉니다. "오래된 값도 계속 반영하긴 하는데, 오래될수록 발언권을 줄인다." 최신 값에 가장 큰 지분을 주고, 과거는 갈수록 희미해지게 두는 거죠.

말로 다 했으니 이제 수식을 봐도 안 무섭습니다.

EMA_t = α · RSSI_t + (1 - α) · EMA_{t-1}

지금 값(RSSI_t)과 직전까지의 평균값(EMA_{t-1})을 비율로 섞는다가 전부입니다. α(알파)는 0과 1 사이의 숫자이고, 이 섞는 비율을 정합니다.

  • α낮으면 → 새 값을 조금만 반영 → 아주 부드러워지지만 실제 움직임에 늦게 반응합니다.
  • α높으면 → 새 값을 많이 반영 → 빠릿하게 따라오지만 노이즈도 같이 따라옵니다.

부드러움과 반응 속도를 맞바꾸는 손잡이인 셈이에요. 제 플러그인의 기본값은 **emaAlpha = 0.2**입니다. 새로 들어온 값에 20%, 지금까지 쌓인 평균에 80%.

에어컨은 왜 26도에서 껐다 켰다 하지 않을까

이제 평활화된 RSSI가 생겼으니, 기준선 하나 그어놓고 "이 위면 가까움, 아래면 멂"이라고 하면 끝일까요? 아쉽게도 아닙니다. 그리고 이 대목이 이 파이프라인에서 가장 재밌는 부분이에요.

에어컨 얘기를 잠깐 할게요. 설정 온도를 26도로 맞춰뒀는데, 에어컨이 26도가 넘으면 켜고 26도 아래면 끈다는 규칙으로만 동작한다고 해봅시다.

방 온도는 정확히 26도에 딱 멈춰 있지 않습니다. 26.1도, 25.9도, 26.05도… 경계선 근처에서 미세하게 오르내리죠. 그때마다 에어컨은 켜짐-꺼짐-켜짐-꺼짐을 반복합니다. 딸깍딸깍딸깍. 컴프레서 수명은 갈려나가고 사람은 시끄러워서 미칩니다.

그래서 온도조절기는 켜는 온도와 끄는 온도를 다르게 둡니다. 예를 들어 26.5도가 되면 켜고, 25.5도까지 내려가야 끄는 식이죠. 그 사이 구간에서는 아무 일도 하지 않습니다. 이 "아무 일도 안 하는 구간"이 바로 히스테리시스예요.

RSSI도 똑같은 문제를 겪습니다. 평활화를 거쳤어도 경계선에 걸쳐 있으면 상태가 왔다 갔다(flapping) 하거든요.

이제 이걸 파티장에 놓아보세요. 나한테 투표한 사람이 딱 애매한 거리에 서서 이야기를 하고 있습니다. 그 사람은 가만히 서 있는데 내 주머니에서 웅— (멈춤) 웅— (멈춤) 웅— 이 반복됩니다.

일단 성가십니다. 그런데 더 큰 문제가 따로 있어요.

진동이 켜졌다 꺼졌다 하면, 그 타이밍이 누가 움직였는지와 맞아떨어지기 시작합니다. 저쪽 사람이 한 발 다가올 때 울리고 물러설 때 멈추면, 몇 번만 반복돼도 누군지 짐작이 갑니다. 익명으로 두려고 이름을 뺐는데, 신호가 딸깍거리는 것만으로 이름이 새는 거죠.

그러니까 여기서 히스테리시스는 UI를 매끄럽게 만드는 장치가 아닙니다. 설계 의도를 지키는 장치예요. 상태가 한번 정해지면 어지간해서는 안 흔들려야 합니다.

그래서 제 파이프라인은 진입 임계와 이탈 임계를 따로 둡니다.

  • 가까운 상태로 넘어가려면 → 평활화된 RSSI가 높은 진입 임계 위로 올라와야 합니다.
  • 상태로 돌아가려면 → 더 낮은 이탈 임계 아래로 떨어져야 합니다.

즉 들어가는 문과 나가는 문이 다릅니다. 한번 "가까움"에 들어오면 어지간히 멀어지기 전에는 안 나갑니다.

실제 기본값은 이렇습니다.

const Thresholds({
  this.enterNearDbm = -60,
  this.exitNearDbm = -65,
  this.enterVeryNearDbm = -52,
  this.exitVeryNearDbm = -56,
  ...
});

near 상태는 -60에서 들어가고 -65에서 나옵니다. veryNear-52에서 들어가고 -56에서 나오고요. 진입과 이탈 사이에 각각 4~5 dBm 정도의 간격이 있죠? 이 간격이 히스테리시스 밴드, 아까 에어컨의 "아무 일도 안 하는 구간"에 해당합니다.

레벨은 far / near / veryNear 세 단계입니다. 많아 보이지 않지만 UI가 표현할 수 있는 상태로는 충분하더라고요.

강도값 — UI에게 dBm을 해석시키지 않기

레벨 세 개만으로는 아쉬울 때가 있습니다. "조금씩 가까워지는 중"을 게이지로 보여주고 싶을 때요.

그래서 마지막 단계에서 평활화된 RSSI를 0.0에서 1.0 사이 값으로 바꿔줍니다. 기준은 minDbm = -80maxDbm = -45 두 개. -80을 0으로, -45를 1로 놓고 그 사이를 비례로 당긴 다음, 범위를 벗어나면 잘라냅니다(클램프). -90이 들어와도 0이고, -30이 들어와도 1이에요.

이 단계가 없으면 UI 코드가 -67 dBm을 받아 들고 "음… 이게 게이지의 몇 퍼센트지?" 하고 직접 계산해야 합니다. 무선 신호 지식이 위젯 코드까지 흘러 들어가는 거죠. 0에서 1 사이 숫자로 넘겨주면 UI는 그냥 그대로 쓰면 되고, 플랫폼도 안 탑니다.

그런데, 사라진 건 어떻게 알죠

여기까지 오면 값도 안정적이고 상태도 안 튑니다. 그런데 한 가지 질문이 남아요.

상대가 그냥 걸어 나가면 어떻게 되죠?

파이프라인은 들어온 샘플만 처리하도록 짜여 있습니다. 샘플이 아예 안 들어오면 처리할 게 없으니 마지막 상태를 그대로 들고 있어요. 코드 입장에서 상대는 사라진 게 아니라 **"아무 소식이 없는 것"**일 뿐이거든요. 그러면 범위 밖으로 나간 기기가 마지막으로 보였던 강도에 영원히 박제됩니다.

파티로 돌아가 볼까요. 그 사람은 진작에 다른 테이블로 옮겨 갔는데, 내 앱은 여전히 "가까이 있음"을 켜둔 채입니다. 이미 없는 사람을 계속 옆에 있다고 알려주는 앱. 조용히 틀리는 쪽이라 더 나쁩니다. 사용자는 앱을 믿고 계속 두리번거리고 있을 테니까요.

그런데 이 타이머의 진짜 값어치는 버그를 막는 데 있지 않습니다.

이런 장면을 생각해보세요. 오른쪽에 앉은 사람이 내 스타일인데, 눈이 높아 보여서 아닐 거라고 지레 접었습니다. 그래서 왼쪽 사람이랑 이야기를 나누고 있었어요. 주머니는 아까부터 조용히 울리고 있었고, 나는 그게 왼쪽 사람일 거라고 생각하고 있었죠.

그런데 오른쪽 사람이 자리를 뜨자 진동이 멎습니다.

...

이 순간을 만들어주는 게 stale 타이머입니다. 진동이 꺼지는 것도 정보예요. 오히려 켜지는 것보다 더 많은 걸 말해줄 때가 있고요. 타이머가 없었다면 그 사람이 나가든 말든 진동은 그대로였을 거고, 저 장면은 영영 안 생겼을 겁니다.

여기서 눈치채셨을 수도 있는데, 앞에서 저는 "신호가 딸깍거리면 익명성이 샌다"고 했습니다. 그런데 방금은 신호가 꺼져서 누군지 알아버렸잖아요. 모순 아닌가요?

아닙니다. 그리고 이 구분이 이 설계의 전부입니다.

  • 딸깍거림은 그 사람이 가만히 서 있는데도 일어납니다. 현실은 아무것도 안 변했는데 신호만 흔들린 거예요. 이건 기계가 만든 잡음이고, 여기서 새는 정보는 거짓에 가깝습니다.
  • 자리를 뜬 것은 현실이 진짜로 변한 겁니다. 그 사람이 실제로 멀어졌고, 신호는 그걸 정직하게 반영했을 뿐이에요.

그러니까 제가 지우려던 건 "정보가 새는 것"이 아니라 "근거 없이 새는 것" 이었습니다. 신호가 현실에만 반응하도록 만들어두면, 거기서 사용자가 뭘 추측하든 그건 본인이 관찰해서 얻어낸 것이 됩니다. 그게 재밌는 부분이고요.

기계는 거짓말만 안 하면 됩니다. 나머지 눈치싸움은 사람들 몫이에요.

값이 들어올 때 뭘 할지는 다들 열심히 설계하는데, 값이 안 들어올 때 뭘 할지는 은근히 잘 안 씁니다. 그래서 stale 타이머를 뒀습니다. 타깃별로 마지막 관측 시각을 기억해두고, 기본 staleMs = 1500 밀리초 동안 새 샘플이 없으면 그 타깃을 강제로 far 레벨 + 강도 0으로 되돌립니다. "소식 없음"을 적극적으로 해석해주는 장치인 셈이에요.

중요 — 이건 거리계가 아닙니다

여기서 제목 얘기를 해야겠네요.

지금까지 설명한 파이프라인의 출력은 미터가 아닙니다. 거리 추정은 v0.1.0의 명시적인 비목표예요. 못 해서 안 하는 게 아니라, 안 하기로 정한 겁니다.

이 글 앞부분이 사실 그 이유였습니다. 같은 자리에 가만히 둬도 펄럭이는 신호로 "몇 미터"를 계산해 내놓으면, 그건 정밀해 보이는 숫자일 뿐이니까요. 그래서 나오는 건 딱 두 가지, 상대적인 근접 레벨정규화된 강도입니다. "몇 미터"가 아니라 "아까보다 가까워졌다" 수준의 정보죠.

이걸 굳이 강조하는 이유는, 구분을 안 하면 결국 UI에 소수점 붙은 거리값을 찍게 되기 때문입니다. 사용자를 속이는 숫자보다는 정직한 게이지 하나가 낫잖아요.

안드로이드에서 기기를 알아보는 문제

짧게 하나만 더 짚고 갈게요. 신호 처리가 아니라 식별 얘기입니다.

BLE 스캔 결과에는 "이 신호는 A 기기에서 온 거야" 하는 이름표, 즉 기기 식별자가 같이 담겨 옵니다. 그런데 일부 안드로이드 버전과 기기에서는 이 식별자가 무작위화되거나 불안정할 수 있어요. 방금 본 기기가 조금 뒤에 다른 이름표를 달고 나타나는 겁니다. (응, 네 이름에 쓰인 글자 우리는 모름~)

평활화도 히스테리시스도 "같은 대상의 시계열"이라는 전제 위에서만 성립합니다. 아무리 신호를 예쁘게 다듬어도 누구 신호인지를 못 붙이면 전부 무의미해요. 파티로 치면, 신호는 아름답게 다듬어놨는데 그게 누구 신호인지를 모르는 겁니다. 그럼 삐 소리를 울릴 수가 없죠.

그래서 이 플러그인은 OS가 주는 식별자로 라우팅하지 않고 토큰 페이로드를 씁니다. 브로드캐스터가 짧은 토큰을 광고에 실어 보내고, 스캐너는 최대 5개까지 타깃 토큰을 등록해두고 그것만 걸러냅니다. 남이 준 이름표는 못 믿으니 내가 정한 표식을 직접 붙이는 거죠.

앞의 시나리오에서 참가자가 앱에 정보를 입력할 때 식별자가 함께 등록된다고 했잖아요. 그 등록된 값이 바로 이 토큰입니다. OS가 주는 MAC 주소는 회전해버려도, 내가 발급하고 서버가 기억하는 토큰은 그대로예요. 그리고 "최대 5개"라는 제약도 이제 자연스럽게 읽힙니다 — 온 세상 기기를 추적하는 게 아니라, 나한테 투표한 소수의 사람만 지켜보면 되니까요.

참고로 여기서 iOS와 Android가 비대칭입니다. Android의 deviceIdMAC 주소 — 바로 그 회전하는 값 — 인 반면, iOS는 앱별로 안정적인 CoreBluetooth UUID를 줍니다. 그래서 iOS에서만 테스트하면 이 함정은 끝까지 안 보여요.

왜 이걸 Dart에 짰나

마지막으로 설계 판단 하나.

평활화, 히스테리시스, 강도 매핑, staleness 판정 — 이걸 전부 네이티브(Kotlin/Swift)에 짤 수도 있었는데, 저는 Dart 쪽에 뒀습니다. 네이티브에 짜면 같은 알고리즘을 두 번 짜야 하고, 그 순간부터 두 구현은 조금씩 어긋나기 시작하거든요. 안드로이드에서만 게이지가 미세하게 다르게 움직이는 버그, 상상만 해도 피곤하잖아요. 그래서 플랫폼 패키지는 광고를 내보내고 원시 스캔 결과를 던져주는 얇은 층으로 두고, 그 위의 판단은 갈라지지 않는 하나의 공유 구현이 담당합니다.

여기에 딸려오는 이득이 하나 더 있어요. 테스트가 쉬워집니다.

SignalProcessor는 현재 시각을 직접 읽지 않고 주입 가능한 now 시계를 받습니다. stale 타이머는 시간이 흘러야 동작하는데, 테스트에서 진짜로 1.5초를 기다릴 수는 없잖아요. 시계를 밖에서 넣어줄 수 있으면 "1600ms 흘렀다고 치자"가 한 줄이 되고, flutter_test에서 결정론적으로 검증됩니다. (시간에 의존하는 코드를 실제 시간으로 테스트하면, 그 테스트는 언젠가 CI에서 랜덤하게 터집니다.)

신호 처리 유틸을 만들었으면 테스트 이야기가 따라와야 한다고 봐요. 숫자를 다루는 코드일수록 눈으로 봐서는 맞는지 알 수가 없으니까요.

마무리

정리하면, 펄럭이는 원시 RSSI를 EMA로 다듬고, 진입/이탈 임계를 벌려서 상태가 딸깍거리지 않게 하고, 0에서 1 사이 값으로 정규화해서 UI에 넘기고, 소식이 끊기면 스스로 far로 되돌린다. 네 단계, 그리고 잊기 쉬운 마무리 하나입니다.

솔직히 시작할 때는 "블루투스로 거리 재는 거 어렵지 않겠지" 정도로 생각했어요. 그런데 파고들수록 진짜 일은 거리가 아니라 흔들리는 값을 사람이 납득할 수 있는 상태로 바꾸는 것이더라고요. 결국 거리 추정은 목표에서 빼버렸고, 후회는 없습니다. 꺄하하.

그래서 그 파티 앱은요

앞에서 미뤄둔 이야기를 해야겠네요.

이 구상은 제품이 되지 못했습니다. 신호 처리가 어려워서가 아니라, 그 앞에 훨씬 시시한 벽이 있었거든요.

참가자 전원이 앱을 깔아야 합니다.

BLE 근접은 양쪽 기기가 다 참여해야 성립합니다. 한쪽만 깔면 아무 일도 안 일어나요. 그러니까 파티장 입구에서 처음 만난 사람들에게 앱을 설치시키고, 권한을 허용받고, 정보를 입력받고, 그게 다 끝나야 비로소 첫 번째 삐 소리가 가능해집니다. 파티가 시작되기도 전에 진이 빠지겠죠. (기술적으로 가능한 것과 사람들이 실제로 해줄 것 사이의 거리는, 제 경험상 늘 제가 예상한 것보다 멉니다.)

그래서 남은 건 파티 앱이 아니라 그 밑에 깔려던 신호 처리 층입니다. 지금 이 글에서 설명한 그거요. 계기는 사라졌는데 부품은 살아남았고, 저는 이 결말이 꽤 마음에 듭니다. 어차피 "가까이 왔는지를 안정적으로 판정한다"는 문제는 파티 말고도 쓸 데가 있으니까요.

혹시 비슷한 걸 만드시다가 상태가 자꾸 튄다면, 십중팔구 히스테리시스 밴드가 좁거나 아예 없는 겁니다. 에어컨을 떠올려보세요. 켜는 온도와 끄는 온도가 같으면 안 됩니다.

그리고 제가 잘못 설명한 부분이 있다면 편하게 지적해주세요. 무선 쪽은 저도 하면서 배우는 중이라, 더 나은 방법을 아신다면 정말로 궁금합니다.