| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 백엔드
- javascript
- 도메인설계
- REST API
- 크롤링
- Kotlin
- github actions
- JWT
- 소셜로그인
- 마이그레이션
- React
- python
- 메일자동화
- 온디바이스AI
- typescript
- contextBridge
- sqlite
- next.js
- 상태관리
- FastAPI
- 배포
- Android
- playwright
- 아키텍처
- Vite
- electron
- php
- 프론트엔드
- 보안
- 인증
- Today
- Total
HM소프트
전화 금융사기 탐지 앱 개발기 (1) — 온디바이스 Whisper·LLM 파이프라인 본문

전화 금융사기 탐지 앱을 완전 온디바이스로 설계해 통화 내용이 기기 밖으로 나가지 않게 한 기록. 녹음 감지부터 오디오 정규화, Whisper STT, 경량 LLM 판정까지 JNI로 붙이고, JSON 실패 시 키워드 폴백과 SQLCipher 암호화로 안전망을 친 파이프라인을 담았다.
통화가 끝난 뒤에야 깨닫는다 — 무엇을, 왜 만들었나
전화 금융사기(보이스피싱)의 가장 고약한 점은, 당하는 그 순간에는 좀처럼 의심이 들지 않는다는 것이다. "검찰입니다", "고객님 계좌가 범죄에 연루됐습니다", "지금 바로 안전계좌로 옮기셔야 합니다" — 이 흐름은 상대를 긴장시켜 판단력을 마비시키도록 정교하게 설계돼 있다. 그래서 피해자 대부분은 통화가 끝나고 한참 뒤, 혹은 송금까지 마치고 나서야 "뭔가 이상했다"를 깨닫는다.
여기서 출발했다. 통화 중에 실시간으로 끼어들어 막는 건 기술적으로도 법적으로도 까다롭다. 대신 사용자가 자기 휴대폰에 이미 저장해 둔 본인 통화 녹음 파일을 사후에 분석해, 사기 의심 정황이 보이면 알려주는 안드로이드 앱을 만들기로 했다. "한 번 당해도 두 번은 막는다", 그리고 "내 통화 패턴을 스스로 점검할 수 있게 한다"는 게 목표였다.
설계에서 가장 먼저, 그리고 가장 강하게 못 박은 원칙은 단 하나다.
> 통화 내용은 절대 기기 밖으로 나가지 않는다.
통화 녹음은 한 사람이 가질 수 있는 가장 민감한 개인 데이터에 속한다. 가족과의 대화, 금융 정보, 건강 이야기가 전부 들어 있다. 이걸 "분석해 드리겠습니다" 하고 외부 서버로 올려 보내는 순간, 그 앱은 보호 도구가 아니라 또 하나의 거대한 유출 위험이 된다. 보이스피싱을 막겠다는 앱이 정작 더 큰 프라이버시 사고의 진원지가 되는 건 자기모순이다.
그래서 음성 인식(STT)도, 사기 패턴 판정(LLM)도 전부 휴대폰 안에서 끝내는 완전 온디바이스 구조로 갔다. 네트워크 권한조차 매니페스트에서 빼버렸다. 구조 자체로 "데이터가 나갈 통로가 없음"을 보장하는 쪽이, 정책 문구로 "안전하게 처리합니다"라고 약속하는 것보다 훨씬 믿을 만하다고 봤다.
스택은 Kotlin + Jetpack Compose로 UI를, Hilt로 의존성 주입을, Room + SQLCipher로 암호화 저장소를 구성했다. 그리고 무거운 추론 두 덩어리 — 음성→텍스트, 텍스트→판정 — 는 C++ 추론 엔진을 CMake로 빌드해 JNI로 붙였다. 이번 글에서는 이 파이프라인을 처음부터 끝까지, 실제 코드에 근거해 따라간다.
전체 그림 — 통화에 개입하지 않는 사후 분석
먼저 큰 그림부터 보자. 이 앱은 통화 그 자체에 일절 손대지 않는다. 통화를 가로채거나, 실시간 녹음을 켜거나, 발신·수신에 끼어들지 않는다. 그저 사용자가 기기 기본 기능으로 남긴 녹음 파일이 저장소에 새로 떨어지는 순간을 감지해, 백그라운드에서 조용히 분석할 뿐이다.

핵심 흐름은 단순하다. 감지 → 중복 확인 → 오디오 정규화 → STT → LLM 판정 → 저장/알림. 이 가운데 무거운 두 단계(STT, LLM)는 전부 네이티브로 내려가고, 나머지는 Kotlin 코루틴 위에서 돈다. 각 단계를 하나씩 뜯어보자.
녹음 파일 감지 — 제조사 파편화와 "다 써진 시점"
가장 먼저 풀어야 했던 현실적인 문제는, 제조사마다 통화 녹음을 저장하는 경로가 제각각이라는 것이었다. 어떤 기기는 Recordings/Call에, 어떤 기기는 MIUI/sound_recorder/call_rec에, 또 어떤 기기는 Sounds/CallRecording에 저장한다. 표준이 없다. 그래서 "정답 경로 하나"를 찾는 대신, 후보 경로 목록을 두고 실제로 존재하는 폴더에만 옵저버를 거는 방식으로 풀었다.
// RecordingObserver.kt — 제조사별 경로에 옵저버 부착
companion object {
// 제조사별 통화 녹음 경로 (표준이 없어 후보를 나열)
private val RECORDING_PATHS = listOf(
"Recordings/Call", // 일부 제조사
"MIUI/sound_recorder/call_rec", // 일부 제조사
"Record/Call",
"Call",
"Sounds/CallRecording",
"Music/Recordings",
)
private val AUDIO_EXTENSIONS = setOf("mp3", "m4a", "wav", "amr", "ogg", "3gp")
}
fun startWatching() {
val externalStorage = Environment.getExternalStorageDirectory()
RECORDING_PATHS.forEach { relativePath ->
val dir = File(externalStorage, relativePath)
if (dir.exists() && dir.isDirectory) {
createObserver(dir) // 존재하는 폴더에만 건다
}
}
}
존재하지 않는 경로에 FileObserver를 거는 건 의미가 없을뿐더러 불필요한 리소스를 잡는다. 그래서 dir.exists() && dir.isDirectory로 걸러 실제 폴더에만 옵저버를 부착하고, 부착한 옵저버들은 리스트로 모아 뒀다가 서비스가 죽을 때 한꺼번에 정리한다.
다음 함정은 "언제 파일을 읽기 시작할 것인가"였다. FileObserver는 여러 종류의 이벤트를 쏘는데, 녹음이 *시작되는* 순간(CREATE)이나 *쓰이는 중*(MODIFY)에 파일을 잡으면, 아직 다 써지지 않은 잘린 오디오를 읽게 된다. 끝부분이 통째로 없는 녹음을 분석하면 STT 결과가 엉망이 된다.

이벤트 마스크를 CLOSE_WRITE or MOVED_TO로만 좁힌 게 핵심이다. CLOSE_WRITE는 어떤 프로세스가 파일에 쓰기를 마치고 핸들을 닫은 시점을 뜻하고, MOVED_TO는 임시 폴더에서 최종 위치로 이동을 마친 시점을 뜻한다. 녹음 앱들은 보통 둘 중 한 방식으로 "완성"을 알리므로, 이 둘만 받으면 "다 써지고 닫힌" 파일만 깔끔하게 집어낼 수 있다. 확장자 화이트리스트로 한 번 더 걸러, 같은 폴더에 생기는 잡파일(임시 파일, 썸네일 등)은 무시한다.
오디오 정규화 — Whisper가 원하는 형태로
녹음 파일이 감지되면 곧바로 STT에 넣을 수 있을까? 아니다. 휴대폰 녹음 파일은 코덱도(m4a, amr, …), 샘플레이트도(8kHz, 44.1kHz, …), 채널도(모노/스테레오) 제각각이다. 반면 Whisper 계열 STT 모델은 입력 형식이 고정돼 있다 — 16kHz, 모노, [-1, 1]로 정규화된 PCM float 배열. 그래서 어떤 입력이 들어오든 이 한 가지 형태로 변환하는 정규화 단계가 필요했다.

디코딩 자체는 안드로이드 표준 MediaExtractor로 처리해 트랙에서 16-bit PCM 샘플을 뽑은 뒤, 위 함수가 세 가지를 한 번에 한다. (1) 스테레오면 채널 평균으로 모노 다운믹스, (2) 선형 보간으로 16kHz 리샘플링, (3) ±32768 정수를 32768.0으로 나눠 [-1, 1] float로 정규화.
여기서 의도적으로 단순하게 간 부분이 리샘플링이다. 정석대로면 안티앨리어싱 필터를 건 sinc 보간을 써야 음질 손실이 적다. 하지만 우리 목적은 고음질 재생이 아니라 음성 인식이다. 사람 목소리의 핵심 정보는 대부분 수 kHz 이하에 몰려 있고, STT 모델은 약간의 보간 왜곡에 꽤 강건하다. 그래서 "정확한 신호 처리"보다 "기기에서 빠르게 도는 가벼운 변환"을 택했다. 온디바이스에서는 모든 단계의 비용이 곧 배터리이자 발열이라, 이런 트레이드오프 판단이 곳곳에 들어간다.

STT와 LLM — 네이티브로 내려보내고, 스텁으로 먼저 달린다
정규화된 오디오는 두 단계의 추론을 거친다. 첫째 STT(Whisper)로 통화를 텍스트로 옮기고, 둘째 경량 LLM으로 그 텍스트가 사기인지 판정한다. 둘 다 모델 크기가 수백 MB~수 GB에 달하는 무거운 연산이라, 자바 레이어에서 돌릴 수 없다. C++로 빌드한 추론 라이브러리를 JNI로 붙였다.
Kotlin 쪽 엔진 클래스는 의외로 얇다. 하는 일은 (1) System.loadLibrary로 네이티브 .so를 올리고, (2) APK assets에 든 모델 파일을 내부 저장소로 복사한 뒤, (3) 네이티브 핸들(Long 포인터)을 받아 들고 있다가, (4) 추론 때 그 핸들을 넘겨 호출하는 것뿐이다.
// WhisperEngine.kt — assets 모델 복사 + JNI 핸들 관리
companion object {
private const val MODEL_NAME = "whisper-small.bin"
init { System.loadLibrary("whisper_jni") }
}
private var nativeHandle: Long = 0
private var isInitialized = false
fun initialize() {
if (isInitialized) return
// assets의 모델을 내부 저장소로 1회 복사 (이미 있으면 건너뜀)
val modelPath = context.assets.open("models/$MODEL_NAME").use { input ->
val file = java.io.File(context.filesDir, MODEL_NAME)
if (!file.exists()) file.outputStream().use { input.copyTo(it) }
file.absolutePath
}
nativeHandle = nativeInit(modelPath) // C++ 포인터를 Long으로 받는다
isInitialized = nativeHandle != 0L
}
fun transcribe(audioData: FloatArray): String {
if (!isInitialized) initialize()
return nativeTranscribe(nativeHandle, audioData)
}
// JNI 경계
private external fun nativeInit(modelPath: String): Long
private external fun nativeTranscribe(handle: Long, audioData: FloatArray): String
private external fun nativeFree(handle: Long)
모델을 왜 assets에서 내부 저장소로 복사하느냐면, 네이티브 추론 엔진은 보통 일반 파일 경로(fopen 가능한 경로)를 요구하는데 APK assets 안의 파일은 압축돼 있어 직접 경로로 열리지 않기 때문이다. 그래서 첫 실행 때 한 번만 풀어서 내부 저장소에 두고, 이후엔 그 경로를 재사용한다. nativeHandle을 Long으로 들고 있는 것도 전형적인 JNI 패턴이다 — C++ 객체 포인터를 자바의 long으로 캐스팅해 왕복시키고, 다 쓰면 nativeFree로 명시 해제한다. GC가 네이티브 메모리까지 챙겨 주지 않으므로 이 해제를 빼먹으면 곧장 누수다.
여기서 개발 생산성에 결정적이었던 결정이 하나 있다. 바로 JNI 스텁이다. 수 GB짜리 실제 모델과 무거운 추론 라이브러리를 매번 빌드에 포함하면, 앱 구조를 잡는 단계에서 빌드 한 번에 몇 분씩 날아가고 기기 메모리도 버겁다. 그래서 시그니처는 똑같지만 가짜 응답을 돌려주는 스텁 .cpp를 따로 두고, CMake가 그쪽을 빌드하게 했다.

# CMakeLists.txt — 스텁 라이브러리 타깃
# ARM NEON 최적화 (실모델 연동 시 추론 속도에 직결)
if(ANDROID_ABI STREQUAL "arm64-v8a")
add_compile_options(-march=armv8-a+simd)
endif()
# 실제 소스 없이 빌드/테스트용 스텁 .so 두 개
add_library(whisper_jni SHARED ${WHISPER_DIR}/whisper_jni_stub.cpp)
target_link_libraries(whisper_jni log android)
add_library(llama_jni SHARED ${LLAMA_DIR}/llama_jni_stub.cpp)
target_link_libraries(llama_jni log android)
스텁 덕분에 모델을 한 줄도 연동하지 않은 상태에서 감지 → 정규화 → (가짜)STT → (가짜)판정 → 저장 → 알림까지 전체 파이프라인을 실제 기기에서 돌려볼 수 있었다. 스텁의 가짜 전사 문장을 일부러 "○○은행입니다… 지금 바로 송금" 같은 전형적 사기 스크립트로 만들어 둔 것도 의도적이다. 이러면 뒤따르는 LLM 판정·키워드 폴백·경고 알림까지 한 번에 양성 케이스로 검증된다. 무거운 ML을 붙이는 프로젝트에서 "추론기를 가짜로 갈아끼울 수 있는 경계"를 처음부터 두는 건, 나중에 모델을 교체하거나 A/B 할 때도 그대로 가치를 발휘한다.
LLM 프롬프트 — JSON으로만 답하라, 그러나…
LLM 판정 단계의 핵심은 프롬프트다. 통화 전사 텍스트를 받아, 사기 확률·유형·근거 키워드·요약을 구조화된 형태로 받아내야 뒤 단계에서 기계적으로 처리할 수 있다. 그래서 "오직 JSON으로만 답하라"고 못 박은 프롬프트를 4B급 경량 LLM의 대화 템플릿에 맞춰 구성했다.
// LlamaEngine.kt — 구조화 출력을 강제하는 프롬프트
private fun buildPrompt(transcription: String): String {
return """<bos><start_of_turn>user
당신은 보이스 피싱 탐지 전문가입니다.
다음 통화 내용을 분석하여 보이스 피싱 여부를 판단해주세요.
[통화 내용]
$transcription
다음 JSON 형식으로만 응답하세요:
{
"probability": 0-100,
"type": "금융사기|기관사칭|가족사칭|기타|해당없음",
"keywords": ["키워드1", "키워드2"],
"summary": "판단 근거 요약",
"language": "ko|en"
}
<end_of_turn>
<start_of_turn>model
"""
}
<bos>, <start_of_turn> 같은 특수 토큰은 사용한 경량 LLM의 대화 포맷 규약이다. 이 마커를 정확히 맞춰야 모델이 "사용자 발화 → 모델 응답" 턴 구조를 제대로 인식한다. 출력 토큰 수는 512로 제한했다 — 판정 결과 JSON은 길어야 몇 줄이고, 온디바이스에서 토큰 하나하나가 곧 시간·전력이라 넉넉히 자르기보다 빠듯하게 잡았다.
문제는, 경량 LLM에게 "JSON으로만 답하라"고 해도 종종 말을 안 듣는다는 것이다. 앞에 "네, 분석해 드리겠습니다" 같은 사족을 붙이거나, JSON 뒤에 설명을 덧붙이거나, 따옴표를 빼먹는다. 클라우드의 대형 모델이라면 함수 호출(structured output) 같은 안전장치가 있지만, 4B급 온디바이스 모델에 그런 보장은 없다. 그래서 출력 파싱은 관대하게, 그리고 절대 죽지 않게 설계해야 했다.
출력을 믿지 않는 파서 — 키워드 폴백이라는 안전망
응답에서 가장 바깥쪽 {와 마지막 } 사이만 잘라내 JSON으로 시도하고, 그래도 실패하면 규칙 기반 키워드 분석으로 폴백한다. 핵심은 "어떤 입력이 와도 예외로 죽지 않고 항상 결과를 돌려준다"는 것이다.

optInt, optString처럼 전부 "기본값을 주는" 접근자를 쓴 것도 의도적이다. 모델이 keywords 필드를 통째로 빼먹어도, probability만 주고 summary를 안 줘도, 빈 값으로 메워 절대 NullPointerException이 나지 않게 했다. LLM 출력은 신뢰할 수 없는 입력으로 취급하는 게 안전하다.
진짜 안전망은 폴백 쪽이다. JSON이 통째로 깨졌을 때 호출되는 analyzeWithPatterns는, 전사 텍스트(혹은 깨진 LLM 응답)에서 직접 사기 키워드를 찾아 점수를 매긴다. 카테고리는 네 가지로 나눴다.
// PhishingAnalyzer.kt — 규칙 기반 키워드 사전(발췌)
private val PHISHING_PATTERNS_KO = mapOf(
"금융사기" to listOf("대출", "저금리", "한도", "송금", "입금",
"카드번호", "비밀번호", "인증번호", "OTP"),
"기관사칭" to listOf("검찰", "경찰", "금융감독원", "국세청", "수사",
"체포영장", "출석", "계좌동결", "명의도용"),
"가족사칭" to listOf("엄마", "아빠", "아들", "딸", "사고",
"돈 보내", "빌려", "급하게"),
"긴급유도" to listOf("지금 당장", "빨리", "시간 없", "오늘까지",
"늦으면", "큰일", "긴급"),
)
// 영어 패턴(PHISHING_PATTERNS_EN)도 별도로 두고, 매칭 시 한국어 유형으로 매핑
키워드가 한 번 걸릴 때마다 해당 유형에 점수를 더하고, 가장 점수가 높은 유형을 결과로 택한다. 점수를 100으로 클램프해 확률처럼 쓰되, 30 미만이면 "해당없음", 50 이상이면 요약에 "의심 키워드 N개 발견"을 붙인다. 영어 패턴도 따로 두고, 영어로 매칭되면 결과 유형은 다시 한국어("금융사기" 등)로 매핑해 UI 표기를 통일했다.
이 폴백을 둔 이유는 분명하다. 온디바이스 LLM은 언제든 죽거나 헛소리를 할 수 있고, 그 순간에도 탐지가 0이 되면 안 된다. 정확도는 LLM이 끌어올리되, 최저선은 규칙 기반이 지킨다. 모델 품질에 전적으로 의존하는 단일 경로 대신, "모델이 실패해도 무너지지 않는" 이중 경로를 만든 셈이다. 이게 사용자 안전과 직결되는 앱에서 내가 가장 중요하게 여긴 설계 원칙이었다.
결과는 어디에 — 끝까지 새지 않게 저장한다
분석 결과는 화면에 띄우고 끝나는 게 아니라, 이력으로 남겨 통계를 보여 줘야 한다. 그런데 이 결과 안에는 통화 전사 텍스트, 의심 키워드, 파일 경로 같은 민감 정보가 그대로 들어간다. 데이터가 기기 밖으로 안 나가게 했다고 해서, 기기 안에 평문으로 쌓아 두면 분실·탈취 시 무방비다. 그래서 로컬 DB 자체를 SQLCipher로 통째 암호화했다.

여기서 중요한 건 암호화 키를 어디에 두느냐다. 키를 코드나 평문 파일에 박으면 암호화는 사실상 무의미하다. 그래서 키는 안드로이드 하드웨어 보안 모듈이 관리하는 Keystore에서 생성·보관한다. SecurityManager가 AES-256-GCM 키를 Keystore에 만들어 두고, DB는 그 키를 패스프레이즈로 받아 암호화 어댑터(SupportFactory)를 Room 위에 끼운다. 결과 테이블의 스키마는 다음과 같이 전사·키워드·확률·유형을 모두 담는다.
// AnalysisResult.kt — 민감 정보를 담는 결과 엔티티
@Entity(tableName = "analysis_result")
data class AnalysisResult(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val recordingPath: String,
val recordingHash: String, // SHA-256, 중복 분석 방지 키
val transcription: String?, // 통화 전사 (민감)
val phishingProbability: Int,
val phishingType: String,
val suspiciousKeywords: String, // JSON 배열
val analysisSummary: String?,
val analyzedAt: Long,
val language: String = "ko"
)
recordingHash(SHA-256)는 단순한 식별자가 아니라 중복 분석을 막는 키다. 분석 서비스는 새 파일을 받으면 먼저 해시를 계산해 DB에 같은 해시가 있는지 보고, 있으면 그냥 건너뛴다. 같은 녹음을 두 번 비싸게 추론하는 낭비를 막고, 옵저버가 한 파일에 이벤트를 중복으로 쏘는 경우(일부 기기에서 발생)에도 방어가 된다. 저장 후 위험도가 50 이상이면 높은 우선순위 채널로 경고 알림을 띄워, 사용자가 "방금 그 통화, 위험했다"를 바로 알 수 있게 했다.
권한은 최소로, 그리고 매니페스트도 코드다
프라이버시를 1원칙으로 둔 앱답게, 권한도 최소주의로 갔다. 통화에 개입하지 않으니 마이크 권한(RECORD_AUDIO)은 아예 요청하지 않는다 — 이건 사용자 신뢰에 직결되는 부분이다. 대신 오디오 파일 읽기(READ_MEDIA_AUDIO), 포그라운드 서비스, 알림, 부팅 자동 시작 정도만 선언했다.

백그라운드 상주 서비스는 최신 안드로이드 정책상 용도를 명시해야 한다. 이 앱의 상시 감지는 정해진 표준 카테고리(미디어 재생, 위치 등)에 안 들어맞아 specialUse 타입으로 선언하고, 서브타입(voice_phishing_detection)을 <property>로 명시했다. 또 allowBackup="false"와 데이터 추출 규칙으로, 분석 결과가 클라우드 백업이나 기기 이전 경로로 새어 나가지 않게 막았다. 어렵게 온디바이스로 가둔 데이터가 OS 백업 기능을 통해 빠져나가면 말짱 도루묵이기 때문이다.
그리고 이 앱의 첫 버그픽스가 바로 매니페스트에서 나왔다는 점이 시사적이다. 초기 커밋 직후, 앱이 시작과 동시에 죽는 문제가 있었다. 원인은 기능 코드가 아니라 매니페스트였다. 초기화 라이브러리(앱 시작 시점 컴포넌트 초기화용)의 프로바이더에, 리팩터링 과정에서 이미 삭제한 초기화 클래스의 선언만 덩그러니 남아 있었던 것이다. 존재하지 않는 클래스를 OS가 초기화하려다 곧장 크래시. 해당 선언을 들어내며 해결했다.
별것 아닌 듯 보이지만 교훈은 분명하다. 매니페스트는 IDE 리팩터링이 따라가 주지 않는 사각지대다. 클래스를 지우면 코드 참조는 IDE가 빨갛게 잡아 주지만, 매니페스트 문자열로 박힌 클래스명·프로바이더 선언은 조용히 남는다. 이 흔적이 빌드는 통과시키고 런타임에 터진다. "매니페스트도 코드다 — 클래스를 지울 땐 선언부도 같이 본다"를 그 자리에서 다시 새겼다.
부팅 자동 시작과 루팅 탐지 — 운영의 디테일
마지막으로 두 가지 운영 디테일. 첫째, 감지 서비스는 사용자가 앱을 켜 두지 않아도 동작해야 의미가 있다. 그래서 부팅 완료 브로드캐스트를 받아 자동으로 서비스를 다시 띄운다. 단, 무조건이 아니라 사용자가 켜 둔 설정값을 확인한 뒤에만 시작한다.
// BootReceiver.kt — 설정 확인 후 자동 시작
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
intent.action == "android.intent.action.QUICKBOOT_POWERON") {
// 암호화 저장소에 둔 사용자 설정을 먼저 확인
val autoStart = prefsManager.getBoolean("auto_start_on_boot", true)
if (autoStart) {
context.startForegroundService(
Intent(context, CallMonitorService::class.java))
}
}
}
설정값 자체도 평문 SharedPreferences가 아니라 암호화된 설정 저장소(EncryptedSharedPreferences)에 둔다. 이 앱은 "사용자 관련 상태"는 사소한 것이라도 암호화된 곳에만 둔다는 일관성을 지켰다. 둘째, 별도의 루팅 탐지기를 둬서 su 바이너리·루트 관리 앱 패키지·which su 결과를 종합해 기기 변조 여부를 점검한다. 루팅된 기기에서는 Keystore·암호화 DB의 보호 가정이 흔들리므로, 민감 데이터를 다루는 앱으로서 최소한의 환경 점검은 필요하다고 판단했다.
정리하며 — 온디바이스 안전 앱을 만들며 배운 것
- 통화 녹음 분석 도구의 제1원칙은 데이터가 기기 밖으로 나가지 않는 것. STT도 LLM도 전부 온디바이스로 가두고, 네트워크 권한 자체를 제거해 "유출할 통로가 없음"을 구조로 보장했다.
- 통화에 개입하지 않고, 사용자가 남긴 녹음 파일의
CLOSE_WRITE/MOVED_TO이벤트만 감지한다. "다 써지고 닫힌" 시점만 잡아야 잘린 오디오를 피한다. - 음질이 아니라 음성 인식이 목적이므로, 리샘플링은 가벼운 선형 보간으로 충분했다. 온디바이스에서는 모든 단계의 비용이 곧 배터리다.
- 무거운 ML은 JNI 스텁으로 갈아끼울 수 있는 경계를 처음부터 뒀다. 모델 한 줄 없이 전체 파이프라인을 실기에서 검증했고, 이 경계는 모델 교체·A/B에도 그대로 쓰인다.
- 경량 LLM의 구조화 출력은 믿을 수 없다. JSON 우선 파싱 + 규칙 기반 키워드 폴백의 이중 경로로, 모델이 죽어도 탐지가 0이 되지 않게 했다.
- 민감 데이터는 Keystore 키로 암호화한 SQLCipher DB에 담고, 설정값까지 암호화 저장소에 둔다.
allowBackup=false로 OS 백업 경로까지 막아야 온디바이스의 의미가 완성된다. - 마이크 권한을 안 쓰는 최소 권한 설계가 곧 신뢰다. 그리고 클래스를 지울 땐 매니페스트 선언도 함께 — 리팩터링 도구가 못 따라오는 사각지대다.
'외주 개발일지' 카테고리의 다른 글
| 취향 큐레이션 커머스 개발기 (2) — 49개 피처 테스트와 사흘간의 버그 사냥 (0) | 2026.07.04 |
|---|---|
| 취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기 (0) | 2026.06.23 |
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |
| 모바일 축구 커리어 게임 개발기 (4) — AdMob 보상형 광고와 게임 재화 연동 (0) | 2026.06.23 |