본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 08. 08:46

인공지능(AI)을 활용한 고가 태블릿의 진정한 잠재력 (Lenovo Legion Tab Gen3)

요약

Lenovo Legion Tab을 활용하여 별도의 앱 설치 없이 Termux와 Claude Code CLI만으로 자동차 ECU 데이터를 읽어오는 OBD Dash 프로젝트를 수행한 경험담입니다. 루팅된 안드로이드 환경에서 셸(Shell) 기반의 코딩 어시스턴트를 활용해 복잡한 문제를 해결하는 과정을 다룹니다.

핵심 포인트

  • Claude Code CLI를 활용한 셸 기반 코딩 어시스턴트 경험
  • Termux와 루팅된 안드로이드를 이용한 리눅스 환경 구축
  • ELM327 동글을 통한 자동차 ECU 데이터 실시간 추출
  • 추가 앱 설치 없이 순수 셸 환경에서 프로젝트 완수

이것은 제가 OBD Dash라는 이름의 심심풀이 프로젝트를 만들었을 때의 여유로운 이야기입니다. 핵심은 제 태블릿이 Bluetooth ELM327 동글을 통해 RPM, 속도, 온도와 같은 자동차 ECU 데이터를 읽을 수 있게 되었다는 것입니다. 흥미로운 점은 어떤 애플리케이션도 설치하지 않고 순수하게 셸(Shell) 내부에서 모든 것이 작동했다는 것이며, 저는 Termux에서 직접 실행되는 코딩 어시스턴트인 Claude Code CLI와 함께 이 작업을 수행했습니다. 과정 중에 많은 난관이 있었고, 하드웨어 탓을 할 뻔하게 만든 미스터리도 있었으며, 화면에 RPM 수치가 실제로 나타났을 때 모든 노력이 가치 있게 느껴졌던 순간도 있었습니다.

Foto Awal

처음에는 그저 제 태블릿이 안쓰러웠습니다

솔직히 말해서, 저는 단순히 영상을 시청하거나 스크롤하는 용도로만 쓰기에는 너무 비싸다고 생각되는 태블릿을 하나 가지고 있습니다. 바로 Lenovo Legion Tab TB321FU입니다. 하이엔드급 태블릿이며, 사양도 강력하고 CPU도 빠릅니다. 하지만 지금까지 그 잠재력의 10분의 1도 채 사용하지 못한 것 같습니다. 정말 아깝다는 생각이 들었습니다.

답답했던 점은 이 기기가 사실 진짜 컴퓨터라는 사실입니다. 저는 이미 Magisk를 사용하여 루팅(Root)을 마쳤고, 그 안의 Linux 터미널인 Termux를 사용하며 살고 있습니다. 그래서 머릿속에는 항상 찜찜함이 남아 있었습니다. 이렇게 강력한 태블릿을 고작 이런 용도로만 써야 한다니?

그러던 어느 날, 재미있으면서도 유용할 것 같은 도전 과제가 떠올랐습니다. 이 태블릿이 제 자동차와 직접 대화할 수 있을까? 즉, OBD-II 포트에 꽂는 저렴한 ELM327 Bluetooth 동글을 통해 RPM, 속도, 냉각수 온도부터 스로틀(Throttle)에 이르기까지 ECU의 데이터를 읽어오는 것입니다. 그런 다음 그 수치들을 브라우저에 크게 표시하는 것이죠.

하지만 저는 스스로에게 한 가지 규칙을 정했습니다. 어떤 애플리케이션도 설치해서는 안 된다는 것이었습니다. Torque도 안 되고, 사이드로딩(Sideload)하는 APK도 안 됩니다. 태블릿은 이미 루팅되었고 Termux도 있으니, 셸에서 바로 가능할 것입니다. 나중에야 깨달았습니다. 그 한 문장의 규칙이 제 시간을 며칠 동안이나 뺏었을 뿐만 아니라, 이 프로젝트를 하나의 모험으로 만들었다는 사실을 말입니다.

코딩 어시스턴트: 루팅된 태블릿 위의 Claude Code CLI

기술적인 내용으로 들어가기 전에, 제가 꼭 이야기하고 싶은 것이 하나 있습니다. 저는 이 작업을 혼자 수행하지 않았습니다. 앞서 언급한 Claude Code CLI가 프로젝트 내내 저와 함께했습니다. 그 친구는 Java 리플렉션 (Java reflection) 프로그램을 구성하고, 이상한 에러들을 추적하며, 그 어떤 튜토리얼에서도 찾을 수 없는 트릭을 찾아내는 것을 도와주었습니다.

흥미로운 점은, 이 Claude Code CLI를 루팅(root)하지 않은 일반 안드로이드에서도 Termux를 통해 설치할 수 있다는 사실입니다. 하지만 태블릿이 루팅되었을 때 비로소 그 잠재력이 온전히 발휘됩니다. 이 프로젝트의 많은 부분들이 su 권한이 있어야만 작동하기 때문입니다. uid 2000으로 app_process를 실행하는 것부터, 일반 앱에는 차단된 시스템 파일을 읽는 것, 그리고 OS 레벨의 요소를 조작하는 것까지 말입니다. 루팅이 없다면 어시스턴트는 제안만 할 수 있을 뿐, 내부까지 직접 실행에 참여할 수는 없습니다.

이것이 바로 제가 이 태블릿의 Android 16을 루팅한 주요 이유입니다. 단순히 스펙을 최대한 활용하기 위해서가 아니라, 코딩 어시스턴트에게도 완전한 권한을 부여하기 위해서였습니다. 그래야만 어시스턴트가 단순한 조언자에 머물지 않고, 처음부터 끝까지 진정으로 도움을 줄 수 있기 때문입니다.

리눅스 유저의 본능이 벽에 부딪히다

리눅스(Linux)에 익숙하다 보니 저의 첫 단계는 자동적으로 이루어졌습니다. Python으로 Bluetooth RFCOMM 소켓을 여는 것이었습니다. 일반적인 리눅스라면 단 다섯 줄이면 충분합니다.

import socket
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)

결과는 AttributeError였습니다. Termux의 Python에는 AF_BLUETOOTH가 없었습니다.

그렇다면 커맨드 라인 도구(command-line tool)를 써보자고 생각했습니다. rfcomm? hcitool? sdptool? 단 하나도 없었습니다. /dev 디렉토리를 살펴보았지만, /dev/rfcomm 역시 존재하지 않았습니다. 저는 서서히 냉혹한 현실을 깨닫기 시작했습니다. 안드로이드는 BlueZ를 사용하지 않습니다. 제가 평생 알고 지냈던 리눅스 블루투스 스택(Bluetooth stack)이 여기에는 전혀 없었습니다.

안드로이드는 Fluoride라는 자체 블루투스 스택을 가지고 있습니다. 그리고 유일한 진입로는 Android Java Bluetooth API를 통해서만 가능합니다. 문제는, 해당 API가 보통 BLUETOOTH_CONNECT 권한을 가진 앱 내부에서만 호출된다는 점이었습니다.

아니, 사실 저는 앱을 만들고 싶지 않다고 계속 고집을 피웠습니다. 이 시점에서 보통의 정상적인 사람이라면 포기하고 그냥 Torque 앱을 설치했을 겁니다. 하지만 저는 오히려 호기심이 더 생겼습니다. 앱을 전혀 사용하지 않고, 일반적인 쉘(shell)에서 Android Java API를 호출하는 것이 정말 가능할까?

app_process 발견, Android 런타임으로 가는 뒷문

알고 보니 틈새가 있었습니다. Android는 모든 애플리케이션을 app_process라는 런타임(runtime)을 사용하여 시작합니다. 그리고 이 app_process는 쉘에서 직접 호출할 수 있습니다. 즉, 작은 Java 프로그램을 작성하고, javacd8을 사용하여 DEX로 컴파일한 다음, app_process를 통해 실행할 수 있다는 뜻입니다. 이 프로그램은 어떤 앱 외부에서도 실행되지만, Bluetooth에 접근 권한을 가진 Android 런타임 내부에서 실행됩니다.

아이디어는 이렇습니다. 이 Java 프로그램이 ELM327로 Bluetooth 연결을 연 다음, 바이트(byte)를 127.0.0.1:35000의 로컬 TCP 소켓(socket)으로 양방향 복사하는 것입니다. 이렇게 하면 외부에서는 해당 포트가 마치 ELM327 WiFi 어댑터처럼 동작하게 됩니다. Android Bluetooth의 모든 복잡함은 대화하기 쉬운 일반 TCP 소켓 뒤로 숨겨집니다.

ELM327  <- BT SPP / RFCOMM ->  bridge (uid 2000, app_process + su)  <- TCP 127.0.0.1:35000 ->  server Node + dashboard web

머릿속으로는 매우 깔끔해 보였습니다. 하지만 실행 과정에는 여섯 가지의 연속된 함정이 기다리고 있었습니다.

하나씩 발견된 여섯 가지 함정

저는 ObdBridge.java를 작성한 후 가장 간단한 것부터 시도했습니다. Bluetooth 어댑터를 가져와서 연결하는 것이었죠. 결과는 null이었습니다. 그때부터 발견의 드라마가 시작되었습니다.

첫 번째 함정은 root 권한으로 실행하지 말라는 것이었습니다. "어려운 일은 모두 root로 해결한다"는 본능이 여기서는 완전히 틀렸습니다. 제가 root, 즉 uid 0으로 실행했을 때 getAdapter()null을 반환했습니다. 알고 보니 root는 패키지(package) 식별자가 없어서 시스템이 어댑터를 제공하기를 거부하는 것이었습니다. BLUETOOTH_CONNECT 권한을 가진 것은 uid 2000, 즉 shell이었습니다. 그리고 호출 방식 자체에도 함정이 있었습니다.

su 2000 -c '...'      # 정답, uid 2000으로 실행
su -c '...' 2000      # 오답, Magisk가 root로 실행되어 '2000'이 인자(argument)로 처리됨

또한, jar 파일은 uid 2000이 읽을 수 있는 위치, 예를 들어 /data/local/tmp에 두어야 하며, 접근이 차단된 Termux 홈 디렉토리에 두어서는 안 됩니다.

두 번째 함정은 숨겨진 API (hidden API)가 차단되어 있다는 점입니다. 내부 API에 대한 직접적인 호출은 거부됩니다. 먼저 이를 해제해야 합니다.

dalvik.system.VMRuntime.getRuntime().setHiddenApiExemptions("L");

세 번째 함정은 "no Looper" 에러입니다. context와 adapter 호출 시 Looper 관련 에러가 발생합니다. 쉘 (shell) 프로세스는 앱과 달리 Android 메인 스레드 (main thread)를 가지고 있지 않으므로, 수동으로 생성해야 합니다.

android.os.Looper.prepareMainLooper();

네 번째 함정은 가장 짜증 나고 가장 교묘하게 숨겨져 있는 부분입니다. 위의 모든 과정을 거쳤음에도 getAdapter()는 여전히 null을 반환합니다. 심지어 getState()getBluetoothManagerServiceRegisterer() on null이라는 이상한 NPE (NullPointerException)를 던집니다. Stack Overflow에도 없고, 그 어떤 튜토리얼에도 나오지 않는 내용입니다. 결국 AOSP 소스 코드를 뒤져보고 나서야, Android 14 이상 버전의 비앱 (non-app) 프로세스에는 null인 정적 (static) 필드가 하나 있으며 이를 직접 채워줘야 한다는 사실을 발견했습니다.

android.bluetooth.BluetoothFrameworkInitializer
    .setBluetoothServiceManager(new android.os.BluetoothServiceManager());

이 줄을 추가하고 나서야 길이 열렸습니다.

다섯 번째 함정은, 마침내 올바른 경로를 통해 어댑터 (adapter)를 가져올 수 있게 된 것입니다.

ActivityThread.systemMain().getSystemContext()
    .getSystemService("bluetooth").getAdapter();

여섯 번째 함정은 동글 클론 (dongle clone)을 위한 소켓 (socket) 문제입니다. 일반적인 연결은 클론에 의해 거부되는 경우가 있습니다. 가장 매끄럽게 작동하는 방식은 의외로 보안되지 않은 (insecure) 버전이었습니다.

device.createInsecureRfcommSocketToServiceRecord(
    UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"));
socket.connect();

그리고 마침내, 로그에 RFCOMM CONNECTED라는 문구가 나타났습니다. Bluetooth가 동글에 연결된 것입니다. 저는 이것이 결승선이라고 생각했습니다. 하지만 알고 보니 이것은 가장 머리 아픈 부분의 시작일 뿐이었습니다.

정확히 1초 만에 끊기는 미스터리

연결에 성공했습니다. 그러더니 약 0.9초 후에 끊겼습니다. 자동 재연결이 시도되지만, 다시 0.9초 후에 끊깁니다. 마치 메트로놈처럼 계속해서 반복되었습니다.

첫 번째 용의자는 명확했습니다. 이 저렴한 클론 (clone) 동글은 분명 고장 난 것이었습니다. 증상이 딱 저급한 하드웨어 같았습니다. 잠시 연결되었다가, 꺼지고, 다시 반복되는 식이었죠. 저는 몇 시간 동안 동글을 탓했습니다. 타임아웃 (timeout) 설정을 바꾸고, 연결 방식을 바꾸고, 키핑 얼라이브 (keep-alive) 명령을 보내보기도 했습니다. 하지만 그 0.9초의 패턴은 조금도 변하지 않았습니다.

오히려 그 규칙성이 결국 저를 의심하게 만들었습니다. 고장 난 하드웨어는 보통 불규칙하게 실패하기 마련인데, 이건 너무나 일관적이었기 때문입니다. 이는 전자적인 문제가 아니라, 타이머 (timer)나 라이프사이클 (lifecycle)에 기반한 무언가라는 뜻이었습니다. 그러다 한 가지 가능성이 떠올랐습니다. 바로 가비지 컬렉터 (Garbage Collector, GC)였습니다.

일반적인 Android 애플리케이션에서는 Bluetooth 프레임워크 객체들이 앱이 실행되는 동안 살아있습니다. 하지만 제 셸 (shell) 프로세스에서는, 설정 함수가 완료되고 connect()가 반환되는 즉시 JVM이 ActivityThread, Context, BluetoothManager, 그리고 BluetoothAdapter를 아무도 사용하지 않는 객체로 판단했습니다. 그리고 바로 GC를 수행해 버린 것입니다. 그러면 어댑터를 등록했던 BluetoothManagerContext가 사라지는 순간, Android Bluetooth 프로세스는 약 1초 후에 RFCOMM 링크를 회수 (reap)해 버립니다. 데이터가 흐르고 있든 아니든 상관없이 말이죠.

해결책은 의외로 간단했습니다. 객체가 GC되지 않도록 붙잡아 두는 것이었습니다. 방법은 정적 필드 (static field)에 강한 참조 (strong-reference)를 유지하는 것이었습니다.

// 이것이 GC되지 않도록 하세요. 사라지면 약 1초 후에 RFCOMM가 회수됩니다.
static ActivityThread   sActivityThread;
static Context          sContext;
...

다시 실행해 보았습니다. 연결 후 1초, 여전히 연결되어 있습니다. 5초, 30초가 지나도 계속 연결되어 있습니다. 멀쩡한 동글을 한참 동안 탓했던 끝에, 진범은 가비지 컬렉터 (Garbage Collector)였습니다.

알고 보니 개성이 있는 클론 (clone) 동글

GC 문제를 해결한 후에도, 동글 자체에는 제가 하나씩 공부해야 할 몇 가지 기이한 특성들이 남아 있었습니다.

첫째, 절대로 ATZ를 보내지 마십시오. ELM327의 표준 리셋 명령인 이 명령은 클론 제품에서 칩을 리셋시키고

둘째, 클론(clone) 제품은 약 0.7초 동안 연결이 끊겨 있으면 링크를 놓쳐버립니다. 대화할 상대가 없으면 바로 화를 내는 셈이죠. 그래서 클라이언트가 연결될 때까지 브릿지(bridge)가 단 하나의 캐리지 리턴(carriage-return)을 포함한 keepalive를 보내도록 했습니다.

셋째, 빠른 연결(connect)과 끊김(drop)이 반복되면—이는 제가 디버깅(debugging)할 때 자주 발생했는데—클론 제품이 완전히 먹통이 될 수 있습니다. 연결을 수락하자마자 밀리초(ms) 단위로 끊겨버리는 현상입니다. 태블릿의 블루투스(Bluetooth)를 껐다 켜도 소용이 없습니다. 유일한 해결책은 OBD 포트에서 동글(dongle)을 뽑았다가 약 10초 후에 다시 꽂는 것뿐입니다.

저는 TCP 리스너(listener)를 계속 열어두고, 대시보드 아래에서 조용히 블루투스를 재연결하도록 만들었으며, 연결 타임아웃(timeout)은 2.5초로 제한했습니다. 목적은 백그라운드에서 재연결 과정이 복잡하게 일어나더라도, 제 쪽에서는 모든 과정이 매끄럽게 느껴지도록 하기 위함이었습니다.

나머지 작업: 로우 바이트(raw byte)를 의미 있는 숫자로 만들기

브릿지가 안정화되고 나면 가장 짜증 나는 부분은 지나간 것입니다. 남은 작업은 오히려 즐거운 일반적인 웹 스택(web stack) 작업입니다.

TCP 소켓(socket) 35000번 위에 프레임워크 없이 순수 Node.js 서버를 작성했습니다. transport.js는 브릿지 포트에 연결되며 자체 시뮬레이터(simulator)를 내장하고 있습니다. 이 시뮬레이터는 유휴(idle), 가속(acceleration), 크루즈(cruise) 주기를 가진 가짜지만 현실적인 데이터를 생성합니다. 이는 자동차 없이도 집에서 대시보드 레이아웃 전체를 구성할 수 있게 해준 구원투수였습니다. 그다음 obd.js는 ELM327 프로토콜을 처리하며 제 차량이 지원하는 PID를 자동으로 감지합니다. 마지막으로 server.js는 HTTP를 제공하고 SSE, 즉 서버 전송 이벤트(Server-Sent Events)를 통해 브라우저로 값을 푸시(push)하여, 폴링(polling) 없이도 화면의 숫자가 실시간으로 업데이트되도록 합니다.

대시보드는 순수 정적 웹(static web)입니다. 차량 내부에서 인터넷 없이도 작동할 수 있도록 모든 라이브러리는 로컬에 번들(bundle)로 포함했습니다. 드래그(drag)와 크기 조절(resize)에는 GridStack.js를 사용했고, 게이지(gauge)에는 canvas-gauges를, 실시간 그래프에는 uPlot을 사용했습니다. 또한

디스플레이 구성에 있어서 저는 한 가지 원칙을 가지고 있습니다. 운전 중에 슬쩍 봐도 읽기 힘든 예쁜 스피도미터(speedometer)보다는, 크고 명확한 숫자가 중요합니다. 그래서 기본 위젯은 큰 숫자 카드 형태로 구성했으며, 그 안에는 값(value), 미니 바(mini-bar), 최소/최대값(min/max), 그리고 색상 영역(color zone)이 포함됩니다. 이 모든 요소는 GridStack을 사용하여 제가 원하는 대로 드래그하여 배치할 수 있는 조밀한 그리드(grid) 형태로 구성되었습니다. 편집 모드는 안전을 위해 기본적으로 잠겨 있습니다.

시뮬레이션 모드를 실행합니다.

cd ~/obd-dash
./start.sh --sim

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0