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 버튼은 반대 방향으로 원격 폰에 주입

정리하면 내 폰의 카메라 영상은 내 폰 → 원격 폰 → 다시 내 폰으로 한 바퀴를 돕니다. 스크린샷 속 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 / 51 | 11.9 / 17 |
| 페이로드 바이트 (초당 평균 / 최대) | 6,599 / 8,436 | 1,970 / 2,818 |
| characteristic read 지연 (중앙값 / p95, 30회) | 744 / 805 ms | 741 / 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 정도의 연결이면 실사용에는 충분합니다.





































