내 폰(파란 테두리)의 RemoteController 앱 안에 원격 폰(주황 테두리)의 QR 스캔 화면이 보이고, 카메라 영상이 도는 순서가 1~4 번호로 표시된 스크린샷

WebSocket으로 가상 센서 구현하기

1. 들어가며

redroid로 안드로이드를 컨테이너에 띄워 쓰다 보면 금방 벽에 부딪힙니다. 화면과 입력은 원격으로 해결되지만, 카메라와 GPS, 그리고 BLE는 없습니다. 서버에는 그런 하드웨어가 처음부터 없기 때문입니다. 카메라로 코드를 비추고, 그 결과로 근처 BLE 장치의 잠금을 여는 식의 앱이라면 첫 단계부터 막힙니다. 읽을 카메라도, 말을 걸 BLE 라디오도 없으니까요.

그래서 방향을 바꿨습니다. 하드웨어는 이미 손에 있는 폰에 다 있습니다. 폰이 센서와 무선 신호를 WebSocket으로 보내고, 서버가 이를 가상 카메라, 가상 BLE 라디오, 가상 GNSS로 재현하면 됩니다. 컨테이너 안의 앱은 자기가 실제 폰에서 돌고 있다고 믿게 됩니다.

이 글은 그 구조를 정리하고, 특히 BLE를 붙이면서 겪은 “광고 패킷 홍수” 문제를 어떻게 4단계로 풀었는지 다룹니다.

2. 전체 아키텍처

flowchart TB
  subgraph C["📱 클라이언트 · RemoteController 앱"]
    direction TB
    CAM[Camera<br/>ForwardService]
    GPS[Gps<br/>ForwardService]
    BLE[ScoutService<br/>BLE]
  end
  WS(("WebSocket<br/>LAN"))
  R["🖥️ RemoteRelay :8080<br/>(redroid 컨테이너)"]
  C <-->|"카메라 · GPS 업로드 / BLE 양방향"| WS
  WS <--> R

그림 1-1. 클라이언트: 폰의 서비스들이 WebSocket 하나로 서버의 RemoteRelay에 연결

flowchart TB
  R["RemoteRelay :8080"]
  R --> CF[CameraUploadForwarder]
  R --> GF[GpsForwarder]
  R <--> BB["BleBridgeClient<br/>(Bluetooth 모듈)"]
  CF --> RH["remotecamera_helper<br/>:28554"] --> VC[가상 카메라]
  GF --> FG["fakegps_helper<br/>:2947"] --> GH[GNSS HAL]
  BB <--> GS[GattService]
  VC --> APP[["컨테이너 안 앱"]]
  GH --> APP
  GS <--> APP

그림 1-2. 서버: RemoteRelay가 기능별 helper와 가상 센서를 거쳐 컨테이너 앱으로 전달

구성 요소는 크게 둘입니다.

  • 클라이언트(RemoteController 앱): CameraForwardService, GpsForwardService, ScoutService(BLE)
  • 서버(redroid 컨테이너): RemoteRelay가 WebSocket(:8080)으로 모든 스트림을 받아 중계

서버 쪽 경로는 기능별로 나뉩니다.

  • 카메라: CameraUploadForwarder → remotecamera_helper(:28554) → 가상 카메라
  • GPS: GpsForwarder → fakegps_helper(:2947) → GNSS HAL
  • BLE: RemoteRelay → Bluetooth 모듈의 BleBridgeClient → GattService

여기에 두 가지 운영 규칙을 더했습니다.

  • 기능별 소유권: camera, gps, ble는 한 번에 한 컨트롤러만 사용. claim으로 가져오고 기존 소유자는 revoked 수신
  • 수요 기반 전송: 컨테이너 앱이 카메라를 실제로 열 때만 서버가 demand 메시지 송신, 그때부터 클라이언트 전송 시작 (DemandMonitor가 감지)
stateDiagram-v2
  [*] --> 비어있음
  비어있음 --> A소유: 컨트롤러 A claim
  A소유 --> B소유: 컨트롤러 B claim / A에 revoked
  B소유 --> A소유: 컨트롤러 A claim / B에 revoked
  A소유 --> 비어있음: A 연결 끊김
  B소유 --> 비어있음: B 연결 끊김
  note right of A소유
    camera, gps, ble
    각 기능마다 독립적으로 적용
  end note

그림 6. claim → 사용 중 → revoked 기능 소유권 상태도

수요 기반 전송은 생각보다 효과가 컸습니다. 아무도 카메라를 쓰지 않는데 JPEG를 계속 밀어 넣던 낭비가 사라졌습니다.

3. 기능별 구현

카메라와 GPS: 내 폰의 센서를 원격 폰에 빌려주기

카메라부터 보겠습니다. 한 문장으로 줄이면 “내 폰의 카메라를 원격 폰에 빌려주는 기능”입니다. 원격 폰(redroid 컨테이너)에는 카메라가 없습니다. 그래서 내 폰이 찍은 영상을 네트워크로 보내고, 원격 폰은 그 영상을 자기 카메라에서 나온 것처럼 앱에 넘겨줍니다.

원격 폰의 앱은 영상이 어디서 왔는지 모릅니다. 평소처럼 카메라를 열었을 뿐인데, 그 카메라에 내 폰이 보고 있는 장면이 들어 있는 것입니다. 앱 입장에서는 그냥 “내 카메라에 무언가가 비쳤다”일 뿐입니다.

아래 스크린샷이 그 모습입니다. 화면이 두 겹이라 처음에는 헷갈립니다. 바깥쪽 파란 테두리는 지금 손에 들고 있는 내 폰의 RemoteController 앱입니다. 안쪽 주황 테두리는 그 앱이 띄워 주는 원격 폰의 화면입니다. 원격 폰에서 QR 스캔 앱을 실행해 두고, 그 화면을 내 폰으로 다시 보고 있는 상태입니다. 그림 속 번호를 따라가면 영상이 한 바퀴 도는 길이 보입니다.

  • ① 내 폰 카메라 촬영: 원격 폰의 앱이 카메라를 여는 순간 서버가 demand 메시지 송신, 내 폰의 CameraForwardService가 실제 카메라로 촬영 시작. 앱 상단 CAM 스위치가 이 기능의 켜짐 상태
  • ② 원격 폰으로 전송: 찍은 프레임을 JPEG로 압축해 WebSocket으로 송신 → 원격 폰의 RemoteRelay → CameraUploadForwarder → remotecamera_helper(:28554) → 가상 카메라. QR 스캔 테두리 안에 보이는 QR 코드가 바로 내 폰 카메라가 보고 있는 장면
  • ③ 원격 폰 앱의 인식: 원격 폰의 QR 스캔 앱이 안드로이드 표준 카메라 API로 가상 카메라를 열고, 들어오는 프레임을 자기 카메라 입력으로 처리. 앱 코드 수정 없음
  • ④ 다시 내 폰으로: 원격 폰의 화면 전체를 다시 JPEG로 내 폰에 송신, RemoteController의 SCREEN 탭에 표시. 터치와 BACK·HOME 버튼은 반대 방향으로 원격 폰에 주입
내 폰(파란 테두리)의 RemoteController 앱 안에 원격 폰(주황 테두리)의 QR 스캔 화면이 보이고, 카메라 영상이 도는 순서가 1~4 번호로 표시된 스크린샷
① 내 폰 카메라 촬영 → ② 원격 폰의 가상 카메라로 전송 → ③ 원격 폰 앱이 자기 카메라로 인식 → ④ 원격 폰 화면이 다시 내 폰에 표시. 파란 테두리는 내 폰, 주황 테두리는 원격 폰 화면

정리하면 내 폰의 카메라 영상은 내 폰 → 원격 폰 → 다시 내 폰으로 한 바퀴를 돕니다. 스크린샷 속 QR 코드는 내 폰 카메라가 찍은 장면이 원격 폰의 앱을 거쳐 다시 내 폰 화면에 나타난 것입니다. 같은 흐름을 구성 요소 단위로 그리면 아래와 같습니다.

flowchart TB
  subgraph MY["📱 내 폰 · RemoteController 앱"]
    CAMR["실제 카메라<br/>CameraForwardService"]
    VIEW["SCREEN 탭<br/>원격 폰 화면 표시"]
  end
  subgraph RM["🖥️ 원격 폰 · redroid 컨테이너"]
    RELAY["RemoteRelay :8080"]
    HELP["remotecamera_helper :28554"]
    VCAM["가상 카메라"]
    APP["QR 스캔 앱<br/>(코드 수정 없음)"]
  end
  APP -. "⓪ 카메라 열림 → demand" .-> CAMR
  CAMR -->|"① 촬영 · ② JPEG 전송"| RELAY
  RELAY --> HELP --> VCAM
  VCAM -->|"③ 일반 카메라 API"| APP
  APP -->|"④ 화면을 JPEG로 되돌림"| VIEW
  style CAMR fill:#dbe7ff,stroke:#2563eb
  style VIEW fill:#dbe7ff,stroke:#2563eb
  style VCAM fill:#ffe6cc,stroke:#f57c00
  style APP fill:#ffe6cc,stroke:#f57c00

그림 1-3. 카메라 영상이 한 바퀴 도는 경로 (번호는 위 스크린샷과 같음)

이 경로에는 실제로 쓰면서 넣은 세부 동작이 몇 가지 있습니다.

  • 수요 기반 시작: 원격 폰의 앱이 카메라를 쓰지 않을 때는 촬영도 전송도 없음. 앱이 카메라를 닫으면 약 3초 뒤 전송 중단
  • 끊김 처리: 전송이 잠깐 끊겨도 가상 카메라가 마지막 프레임을 유지해 앱 화면이 깨지지 않음. 부팅 후 영상이 한 번도 없었을 때만 대체 그림 표시
  • 최신 프레임 우선: 원격 폰 화면을 내 폰으로 보낼 때는 최신 JPEG 한 장만 유지하고 밀린 프레임은 버림

GPS는 같은 방식이지만 더 단순합니다. 내 폰의 위치를 fakegps_helper(:2947)로 보내면, 원격 폰의 GNSS HAL이 이를 실제 위성 신호처럼 앱에 전달합니다. 위치는 한쪽으로 흘려보내기만 하면 되니 되돌아오는 경로가 없습니다.

BLE

BLE는 양방향이라 이야기가 다릅니다.

  • 스캔: 클라이언트가 실제 스캔 수행 → ble_scan_result(address, rssi, advData) 송신 → 서버 앱의 스캔 콜백으로 전달
  • GATT: 서버 앱의 connect/read/write/notify 요청 → 클라이언트로 전달 → 실제 장치와 통신 → ble_gatt_event로 결과 회신
  • MTU 247 요청: 한 번에 실어 보내는 양을 늘려 LAN 왕복 횟수 감소

핵심은 서버의 Bluetooth 모듈 안에 넣은 BleBridgeClient입니다. GattService 입장에서는 이것이 로컬 BLE 라디오처럼 보입니다. 그래서 앱 코드는 한 줄도 바꾸지 않아도 됩니다.

sequenceDiagram
  participant A as 컨테이너 앱
  participant G as GattService
  participant B as BleBridgeClient
  participant R as RemoteRelay
  participant S as ScoutService (폰)
  participant D as 실제 BLE 장치
  A->>G: connectGatt / readCharacteristic
  G->>B: GATT 요청
  B->>R: GATT 명령 (ble_connect 등)
  R->>S: WebSocket (text 레인, 우선)
  S->>D: 실제 GATT 연결 · 읽기
  D-->>S: 응답
  S-->>R: ble_gatt_event
  R-->>B: ble_gatt_event
  B-->>G: 즉시 처리 (큐 우회)
  G-->>A: onCharacteristicRead

그림 2. 앱 connect/read → GattService → BleBridgeClient → relay → ScoutService → 실제 장치 → ble_gatt_event 회신 시퀀스

4. 트러블슈팅: BLE가 매우 느려지다

증상

주변에 BLE 기기가 많은 곳에서 BLE가 눈에 띄게 느려졌습니다. 스캔 목록이 버벅이고, characteristic read 하나에 한참이 걸렸습니다. 따라가 보니 read 응답이 앞서 쌓인 광고 수백 개 뒤에서 차례를 기다리고 있었습니다.

나중에 집에서 다시 재 보니, 60초 동안 보이는 주소가 36~43개(랜덤 주소 포함)인 평범한 환경에서도 폰이 받는 광고만 초당 약 40건이었습니다. 필터가 없으면 이 40건이 하나하나 JSON 메시지가 되어 LAN을 건넙니다.

원인

원인은 세 가지가 겹친 것이었습니다.

  • LOW_LATENCY 스캔은 기기당 초당 10~100개의 광고 보고
  • 클라이언트가 이를 전부 JSON으로 변환해 그대로 송신
  • 스캔 결과와 GATT 이벤트가 같은 FIFO 사용, 서버는 소켓 읽기 스레드에서 무거운 스캔 콜백을 직접 처리

즉 꼭 도착해야 하는 GATT 응답이, 몇 초 뒤면 의미가 없어지는 광고 데이터에 막혀 있었습니다.

flowchart TB
  subgraph BEFORE["개선 전 · 단일 FIFO"]
    direction LR
    b1[광고] --> b2[광고] --> b3[광고] --> b4["… 수백 개 …"] --> b5[광고] --> b6["⏳ GATT read 응답"]
  end
  subgraph AFTER["개선 후 · 우선순위 분리"]
    direction LR
    a1["⚡ GATT read 응답"] --> a2["광고 (주소별 최신 1개)"] --> a3["광고 (주소별 최신 1개)"]
  end
  BEFORE ~~~ AFTER
  style b6 fill:#fde2e2,stroke:#d33
  style a1 fill:#dff5e3,stroke:#2a7

그림 3. 개선 전(광고 뒤에 GATT read가 대기) vs 개선 후(GATT가 앞질러 감) 타임라인 비교

개선 1: 클라이언트 ScanThrottle

보내기 전에 거르는 것이 가장 쌉니다. 판정 규칙은 이렇습니다.

  • 첫 발견은 항상 전송
  • 같은 기기는 300ms 이내 재전송 억제
  • 광고 데이터가 바뀌었거나 RSSI가 6dB 이상 변했을 때만 전송
  • 변화가 없어도 2초마다 keep-alive (기기가 사라진 것처럼 보이지 않도록)
// ScanThrottle.shouldSend 핵심 부분 (발췌)
State s = mStates.get(address);
if (s == null) { s = new State(); mStates.put(address, s); }   // 첫 발견: 전송
else {
    long since = nowMs - s.lastSentMs;
    if (since < mMinIntervalMs) return false;                    // 300ms 억제
    boolean changed = hash != s.advHash || len != s.advLen
            || Math.abs(rssi - s.lastRssi) >= mRssiDelta;        // 광고 변경, RSSI 6dB
    if (!changed && since < mRefreshMs) return false;            // 2초 keep-alive
}

광고 데이터는 바이트 배열을 통째로 비교하지 않고 해시와 길이만 기억해 둡니다. 걸러진 광고는 JSON을 만드는 단계까지 가지도 않습니다.

flowchart TD
  IN([스캔 결과 수신]) --> Q1{처음 본 기기?}
  Q1 -- 예 --> SEND([전송])
  Q1 -- 아니오 --> Q2{"마지막 전송 후<br/>300ms 이내?"}
  Q2 -- 예 --> DROP([버림])
  Q2 -- 아니오 --> Q3{광고 데이터 변경?}
  Q3 -- 예 --> SEND
  Q3 -- 아니오 --> Q4{"RSSI 변화<br/>6dB 이상?"}
  Q4 -- 예 --> SEND
  Q4 -- 아니오 --> Q5{"마지막 전송 후<br/>2초 경과?"}
  Q5 -- "예 (keep-alive)" --> SEND
  Q5 -- 아니오 --> DROP
  style SEND fill:#dff5e3,stroke:#2a7
  style DROP fill:#eee,stroke:#999

그림 4. 첫 발견 → 300ms → 데이터 변화/RSSI 6dB/2초 판정 흐름도

개선 2: 클라이언트 송신 큐

거르고 남은 것도 순서를 바꿨습니다.

  • GATT 이벤트: 순서 유지, 항상 스캔보다 먼저 송신
  • 스캔: 주소를 키로 최신 값만 유지, 이전 값은 덮어쓰기

개선 3: 서버 ClientOutbox 레인 분리

서버에서 클라이언트로 내려가는 방향도 같은 문제가 있었습니다. 큐를 데이터 성격별 레인으로 나눴습니다.

  • text: 제어 메시지와 GATT 명령, 최우선
  • scans: 주소별 최신 값
  • frame: 최신 JPEG 한 장

text를 먼저 비우고, scans와 frame은 번갈아 보내서 한쪽이 다른 쪽을 굶기지 않게 했습니다.

flowchart LR
  T["text 레인<br/>제어 · GATT<br/>순서 보장"]
  SC["scans 레인<br/>주소별 최신 값"]
  FR["frame 레인<br/>최신 JPEG 1장"]
  P{{"1순위: text 먼저 비움"}}
  RR{{"2순위: 라운드로빈"}}
  SND[["sender → WebSocket"]]
  T --> P --> SND
  SC --> RR
  FR --> RR
  RR --> SND
  style T fill:#dff5e3,stroke:#2a7

그림 5. text 우선, scans와 frame은 번갈아 sender로 가는 레인 구조

개선 4: 서버 수신 측 ScanCoalescer

마지막은 서버의 받는 쪽입니다. 소켓을 읽는 스레드가 onScanResult를 직접 부르던 구조를 끊었습니다.

  • 소켓 읽기 스레드: 스캔 결과를 큐에 넣기만 함
  • 별도 처리 스레드: 무거운 onScanResult 호출
  • GATT 이벤트: 큐를 거치지 않고 즉시 처리

공통 원칙

네 단계를 정리하고 보니 결국 원칙은 하나였습니다. 데이터를 두 종류로 나누는 것입니다.

종류예처리 방식
흘러야 하는 데이터영상, 스캔최신 값만 유지, 밀리면 버림
반드시 도달해야 하는 데이터GATT, 제어순서 보장, 항상 우선

결과

항목A: 필터 끔B: ScanThrottle
서버가 받은 ble_scan_result (초당 평균 / 최대)39.9 / 5111.9 / 17
페이로드 바이트 (초당 평균 / 최대)6,599 / 8,4361,970 / 2,818
characteristic read 지연 (중앙값 / p95, 30회)744 / 805 ms741 / 799 ms

ScanThrottle만 끄고 켜서 60초씩 비교했습니다. 서버 쪽 레인 분리와 ScanCoalescer, 폰의 GATT 우선 큐는 두 경우 모두 그대로입니다. 폰은 두 경우 모두 초당 약 40건의 광고를 받았고, 필터를 켜면 그중 30% 정도만 보냈습니다. 메시지 수와 바이트가 약 3.3분의 1로 줄었습니다.

%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#3b6fd4"}}}}%%
xychart-beta
  title "서버가 받은 ble_scan_result (초당, 60초 평균)"
  x-axis ["A: 필터 끔", "B: ScanThrottle"]
  y-axis "메시지/초" 0 --> 45
  bar [39.9, 11.9]

그림 7. 필터를 켜면 같은 광고 흐름에서 전달하는 메시지가 약 3.3분의 1로 줄어듦

반면 read 지연은 거의 같았습니다. 이 환경에서는 필터를 꺼도 폰과 서버의 대기열이 최대 1건 정도라 줄 자체가 생기지 않았습니다. 처음 문제가 됐던 붐비는 환경이 집에서는 재현되지 않은 것입니다. 그래서 이번 측정으로 확인한 것은 트래픽 감소까지입니다. 지연 개선은 예전 단일 FIFO 구조와 붐비는 곳에서 직접 비교해야 드러날 것 같습니다.

측정 조건: Galaxy S9+(LineageOS), 5GHz Wi-Fi, x86_64 redroid 컨테이너, 시나리오별 1회 측정.

5. 검증

  • 회귀 시험: 자동 시험 66 통과 / 1 실패. 실패한 1건은 앞선 시험이 열어 둔 앱 화면이 남아 있어서 생긴 시험 시작 상태 문제로, 이번 BLE 변경과는 관련 없음 (시작 전에 정리하도록 고쳤고 전체 재실행은 아직)
  • 호스트 단위 테스트: ScanCoalescerTest로 주소별 최신 값 유지와 대기열 상한 동작 검증

6. 한계와 다음 단계

네트워크 상태가 나쁘면 GATT 지연이 그대로 앱에 드러납니다. 기능 소유권도 한 번에 한 컨트롤러만 허용하므로, 여러 폰의 센서를 섞어 쓰는 것은 아직 안 됩니다. 측정에서 본 read 한 번에 740ms 안팎이 걸리는 것도 네트워크 왕복만으로는 설명이 안 되는 숫자라 따로 들여다볼 생각입니다.

다음으로는 붐비는 환경에서 예전 단일 FIFO 구조와 지금 구조를 직접 비교해 지연 차이를 확인하고, 남은 회귀 시험 실패 1건을 정리할 계획입니다. 웹 뷰어(클라이언트 목록, 강제 끊기, 회전)와 GApps/ARM 번역 환경 이야기는 따로 한 편으로 다루겠습니다.

하드웨어가 없는 컨테이너에 하드웨어를 “빌려주는” 일은 결국 네트워크 위에 장치를 흉내 내는 일이었습니다. 그리고 그 흉내가 자연스러우려면, 무엇을 버려도 되고 무엇은 절대 버리면 안 되는지를 먼저 정해야 했습니다.

덧붙이면, 이름은 “over LAN”이지만 같은 LAN 안에서만 되는 것은 아닙니다. 폰을 Wi-Fi 대신 5G 모바일 데이터로 붙여서도 써 봤는데, 실제로 쓰는 데는 문제가 없었습니다. LTE나 5G 정도의 연결이면 실사용에는 충분합니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다