

오늘은 데이터 수집을 위해 안드로이드에서 제공하는 SMS, MMS, RCS, Notification의 개념을 정리하고,
각 메시지 타입의 데이터 수집 방법, 실제 필드 파싱, 그리고 처리 과정에서 발생한 문제와 해결 방안을 공유하고자 합니다.
메시지를 수신하고 데이터를 추출하는 작업은 겉보기에는 단순해 보일 수 있지만,
실제로는 다양한 이슈와 시행착오가 존재했습니다.
이 글은 그런 과정 하나하나를 기록하기 위한 첫걸음입니다.
저는 앞으로 데이터 수집 → 변형 → 적재 → 분석 → AI에 이르는 과정을 직접 다루며,
데이터 엔지니어로서의 여정을 기술 블로그를 통해 정리해나가고자 합니다.
| 유형 | 설명 | 주요 특징 |
|---|---|---|
| SMS (Short Message Service) | 160자 이하의 단문 텍스트 메시지 | - 인터넷 없이 통신망으로 전송 - 단일 텍스트로 구조 단순 - BroadcastReceiver로 수신 가능 - context.getContentResolver()로 과거 데이터 조회 |
| MMS (Multimedia Messaging Service) | 이미지, 영상, 오디오 등 멀티미디어 메시지 | - 멀티파트 메시지 구조 - 첨부파일 포함 가능 - Broadcast 수신 불가 → ContentProvider에서 폴링 필요 |
| RCS (Rich Communication Services) | 인터넷 기반 고급 메시징 프로토콜 | - 읽음 확인, 타이핑 표시 등 채팅 기능 - 고화질 미디어 전송 - 통신사/구글 메시지 앱 기반 |
| Notification (알림 시스템) | 앱에서 발생한 이벤트를 사용자에게 알리는 UI 요소 | - 모든 앱에서 사용 가능 - NotificationListenerService로 수신- 제목/본문 등 UI 기반 정보 추출 - 앱마다 포맷 상이하여 파싱 필요 |
공통
data class Sms(
val id: String,
val message: String,
val sender: String,
val displaySender: String,
val date: String,
val type: SmsType
)
enum class SmsType {
SMS, MMS, RCS, Notification
}
object Utils {
fun getConvertedDate(timestampMillis: Long): String {
val date = Date(timestampMillis)
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return sdf.format(date)
}
fun generateHashId(input: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
}
Receiver
// BroadcastReceiver를 상속하여 SMS 수신 이벤트를 감지하는 클래스
class SmsCatcher : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// 브로드캐스트로부터 전달된 데이터(Bundle)를 가져옵니다
val bundle = intent.extras ?: return
// SMS 메시지는 "pdus"라는 이름의 배열로 전달됩니다
val pdus = bundle.get("pdus") as? Array<*> ?: return
// Android 6.0(M) 이상에서는 format이 필요합니다
val format = bundle.getString("format")
// 메시지 본문, 발신자, 표시용 발신자, 타임스탬프 초기화
var messageBody = ""
var sender = ""
var displaySender = ""
var timestamp = 0L
// pdus 배열을 순회하면서 SmsMessage 객체로 변환 및 정보 추출
pdus.mapNotNull { it as? ByteArray } // ByteArray로 캐스팅
.map { pdu ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
SmsMessage.createFromPdu(pdu, format)
} else {
SmsMessage.createFromPdu(pdu)
}
}
.forEach { sms ->
// 다중 메시지를 하나로 이어붙이기
messageBody += sms.messageBody.orEmpty()
// 발신자 번호 추출
sender = sms.originatingAddress.orEmpty()
displaySender = sms.displayOriginatingAddress.orEmpty()
// 타임스탬프는 첫 번째 SMS 기준으로만 설정
if (timestamp == 0L) timestamp = sms.timestampMillis
}
// 최종 SMS 객체 생성 (사용자 정의 모델에 맞게 매핑)
val sms = SMS(
id = Utils.generateHashId(messageBody + sender + date),
msg = messageBody,
sender = sender,
displaySender = displaySender,
date = Utils.getConvertedDate(timestamp),
type = Constants.SmsType.SMS_TYPE_SMS
)
// 디버깅 로그 출력 (실제로는 저장, 파싱 등 처리)
Log.d("SMS_SIMPLE", sms.toString())
}
}
Reciever 등록
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<receiver android:name=".SmsCatcher" android:exported="true">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
import android.content.Context
import android.net.Uri
import android.provider.Telephony
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*
fun getRecentSmsList(context: Context): List<Sms> {
val results = mutableListOf<Sms>()
// 안드로이드의 SMS inbox URI
val uri = Uri.parse("content://sms/inbox")
// 현재 시간 (밀리초 기준)
val endTime = System.currentTimeMillis()
// 3개월 전의 타임스탬프 계산
val startTime = Calendar.getInstance().apply {
add(Calendar.MONTH, -3)
}.timeInMillis
// 날짜 조건 설정: 최근 3개월간 SMS만 조회
val where = "date BETWEEN ? AND ?"
val whereArgs = arrayOf(startTime.toString(), endTime.toString())
// 날짜 기준 오름차순 정렬
val sortOrder = "date ASC"
// ContentResolver로 쿼리 실행
val cursor = context.contentResolver.query(uri, null, where, whereArgs, sortOrder)
cursor?.use {
// 필요한 컬럼 인덱스 정의
val bodyIdx = it.getColumnIndexOrThrow("body")
val addressIdx = it.getColumnIndexOrThrow("address")
val dateIdx = it.getColumnIndexOrThrow("date")
// 커서를 순회하며 SMS 항목 추출
while (it.moveToNext()) {
val body = it.getString(bodyIdx) // 메시지 본문
val address = it.getString(addressIdx) // 발신자 번호
val timestamp = it.getLong(dateIdx) // 수신 일시 (ms)
val dateString = Utils.getConvertedDate(timestamp) // 사람이 읽을 수 있는 날짜 포맷으로 변환
// 메시지 본문 + 발신자 + 날짜 조합으로 고유 ID 생성
val hashId = Utils.generateHashId(body + address + dateString)
// SMS 데이터 클래스 인스턴스 생성
val sms = Sms(
id = hashId,
message = body,
sender = address,
displaySender = address,
date = dateString,
type = SmsType.SMS
)
results.add(sms)
}
}
return results
}
class MmsCatcher : BroadcastReceiver() {
companion object {
const val MMS_ACTION = "android.provider.Telephony.WAP_PUSH_RECEIVED"
}
private var mmsHelper: MmsHelper? = null
override fun onReceive(context: Context, intent: Intent) {
// MMS는 수신 후 inbox에 저장되기까지 딜레이가 있어 지연 처리
Handler(Looper.getMainLooper()).postDelayed({
try {
// MMSHelper를 통해 최근 수신된 MMS 데이터를 가져옵니다
val helper = mmsHelper ?: MmsHelper(context).also { mmsHelper = it }
val mms = helper.getMms()
// 추출한 MMS 로그 출력 (또는 리스트에 저장 등으로 확장 가능)
Log.d("MMS_CATCHER", "MMS received: $mms")
} catch (e: Exception) {
Log.e("MMS_CATCHER", "Error reading MMS", e)
}
}, 3000) // inbox에 완전히 저장되기 위한 지연 (필요 시 조절 가능)
}
}
class MmsHelper(private val context: Context) {
/**
* 가장 최근 수신된 MMS를 읽어와서 SMS 형태로 반환합니다.
* 내부 예외 발생 시 null 반환.
*/
fun getMms(): Sms? = try {
val cursor = context.contentResolver.query(
Uri.parse("content://mms/inbox"),
null,
"msg_box = 1",
null,
"_id DESC LIMIT 1"
) ?: return null
cursor.use {
if (!it.moveToFirst()) return null
val id = it.getInt(it.getColumnIndexOrThrow("_id"))
val timestamp = it.getLong(it.getColumnIndexOrThrow("date")) * 1000
val sender = getMmsAddress(id)
val body = parseMmsBody(id.toString()).take(490)
Sms(
id = generateHashId(body + sender + timestamp.toString()),
message = body,
sender = sender,
displaySender = sender,
date = Utils.getConvertedDate(timestamp),
type = SmsType.MMS
)
}
} catch (e: Exception) {
null
}
/** 발신자 번호를 content://mms/{id}/addr 에서 추출 */
private fun getMmsAddress(messageId: Int): String {
val uri = Uri.parse("content://mms/$messageId/addr")
val cursor = context.contentResolver.query(uri, arrayOf("address"), "msg_id=$messageId", null, null)
?: return ""
cursor.use {
while (it.moveToNext()) {
val raw = it.getString(it.getColumnIndexOrThrow("address"))
if (!raw.isNullOrBlank()) {
return raw.replace("-", "")
}
}
}
return ""
}
/** MMS 본문 파트에서 텍스트 추출 */
private fun parseMmsBody(messageId: String): String {
val uri = Uri.parse("content://mms/part")
val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return ""
cursor.use {
while (it.moveToNext()) {
val mid = it.getString(it.getColumnIndexOrThrow("mid"))
if (mid != messageId) continue
val type = it.getString(it.getColumnIndexOrThrow("ct"))
if (type != "text/plain") continue
val data = it.getString(it.getColumnIndexOrThrow("_data"))
return if (!data.isNullOrBlank()) {
readMmsText(it.getString(it.getColumnIndexOrThrow("_id")))
} else {
it.getString(it.getColumnIndexOrThrow("text")).orEmpty()
}
}
}
return ""
}
/** _data가 있는 경우, InputStream을 통해 텍스트 추출 */
private fun readMmsText(partId: String): String {
val uri = Uri.parse("content://mms/part/$partId")
val builder = StringBuilder()
context.contentResolver.openInputStream(uri)?.use { input ->
BufferedReader(InputStreamReader(input, Charsets.UTF_8)).use { reader ->
var line = reader.readLine()
while (line != null) {
builder.append(line)
line = reader.readLine()
}
}
}
return builder.toString()
}
}
MMS는 content://mms/inbox를 통해 과거 데이터를 조회할 수 있지만, 쿼리 성능이 매우 느리고, 기기/OS 버전에 따라 저장 구조도 다릅니다.
따라서 MMS가 필수 데이터가 아닌 경우, 과거 MMS 조회는 스킵하거나 제한된 범위로 필터링하는 것을 권장합니다.
fun getRecentMmsAsSmsList(cnt: Int): List<Sms> {
val smsList = mutableListOf<Sms>()
var cursor: Cursor? = null
try {
val uri = Uri.parse("content://mms/inbox")
val sortOrder = "date ASC LIMIT $cnt"
cursor = context.contentResolver.query(uri, null, "msg_box=1", null, sortOrder)
cursor?.use {
val idIndex = it.getColumnIndexOrThrow("_id")
val dateIndex = it.getColumnIndexOrThrow("date")
while (it.moveToNext()) {
val id = it.getInt(idIndex)
val timestamp = it.getLong(dateIndex) * 1000
val dateString = Utils.getConvertedDate(timestamp)
val sender = mmsHelper.getMmsAddress(id)
val body = mmsHelper.parseMmsBody(id.toString()).take(490)
val hashId = Utils.generateHashId(body + sender + dateString)
val sms = Sms(
id = hashId,
message = body,
sender = sender,
displaySender = sender,
date = dateString,
type = SmsType.MMS
)
smsList.add(sms)
}
}
} catch (e: Exception) {
Log.e("MMS_PARSE", "Error parsing MMS to Sms", e)
} finally {
cursor?.close()
}
return smsList
}
class RcsCatchReceiver : BroadcastReceiver() {
private var rcsService: RcsService? = null
override fun onReceive(context: Context?, intent: Intent?) {
// context 또는 intent가 null이면 바로 종료
if (context == null || intent == null) return
if (intent.action != RCS_RECEIVED_ACTION) return
try {
// msg_id 는 RCS 메시지를 식별하는 고유 키
val msgId = intent.extras?.get("msg_id")?.toString() ?: return
// RcsService 초기화 후 메시지 조회 및 처리
val service = rcsService ?: RcsService(context).also { rcsService = it }
val sms = service.queryRcs(msgId)
sms?.let {
Log.d("RCS_CATCH", "RCS message received: $it")
// 이후 처리 (파싱, 저장 등) 필요 시 확장 가능
}
} catch (e: Exception) {
Log.e("RCS_CATCH", "Error handling RCS message", e)
}
}
companion object {
const val RCS_RECEIVED_ACTION = "com.services.rcs.MESSAGE_RECEIVED"
}
}
/**
* RCS 전체 메시지를 조건(where) 기반으로 조회
*/
fun getRcsList(where: String? = null, cnt: Int = Int.MAX_VALUE): List<SMS> {
val results = mutableListOf<SMS>()
val uri = "content://im/rcs_read_im".toUri()
val sort = "date ASC LIMIT $cnt"
context.contentResolver.query(uri, null, where, null, sort)?.use { cursor ->
while (cursor.moveToNext()) {
RcsParser.parseRcs(cursor)?.let { results.add(it) }
}
}
return results
}
import android.app.Notification
import android.content.Intent
import android.provider.Telephony
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*
class NotificationCatchService : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn ?: return
val notification = sbn.notification ?: return
val extras = notification.extras ?: return
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val content = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
val packageName = sbn.packageName.orEmpty()
val timestamp = notification.`when`
val dateString = Utils.getConvertedDate(timestamp)
// Sms 객체로 직접 변환
val sms = Sms(
id = Utils.generateHashId(title + content + dateString),
message = if (bigText.isNotEmpty()) bigText else content,
sender = packageName,
displaySender = title,
date = dateString,
type = SmsType.Notification
)
Log.d("NOTI_CATCH", "Parsed SMS: $sms")
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
// 필요시 구현
}
override fun onListenerConnected() {
super.onListenerConnected()
Log.d("NOTI_CATCH", "Listener connected")
}
override fun onListenerDisconnected() {
super.onListenerDisconnected()
Log.d("NOTI_CATCH", "Listener disconnected")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
}
불가능
문제 1: 문자와 알림의 중복 수신 문제
현상:
하나의 문자 수신 이벤트가 실제 문자(SMS_RECEIVED)와 동시에 알림(Notification)으로도 발생 →
동일한 메시지가 두 번 수신되어 파싱/저장이 중복됨
원인:
안드로이드 시스템이 문자 수신 시, 알림 채널을 통해 별도의 Notification을 발송하기 때문
해결 방법:
문제 2: 같은 결제, 다른 출처로 중복 수신되는 문제
현상:
예를 들어 신한은행, 삼성페이 등 하나의 결제에 대해
문자 + 금융앱 알림 + 삼성페이 알림 등 3중 알림/문자 수신 발생 →
동일 금액이 여러 번 인식되는 문제
원인:
다양한 채널이 하나의 결제에 대해 각각 독립적으로 알림/문자를 발송함
해결 방법:
문제 3: 알림이 동일 데이터인데도 중복 수신되는 문제
현상:
같은 알림 내용이 반복 수신됨. StatusBarNotification.postTime이 다르게 설정되어 있어
내용은 동일하지만 중복으로 인식되지 않음
원인:
알림 시스템이 앱 상태, 갱신 주기 등에 따라 동일한 알림을 다시 push함
(postTime이 매번 다르게 들어옴)
해결 방법:
문제 4: 알림 내 필드별 데이터 위치가 다름
현상:
금액, 키워드 등이 알림의 title, text, bigText, subText 등 다양한 필드에 분산되어 있어 파싱 어려움
해결 방법:
안드로이드는 SMS, MMS, RCS, Notification을 통해 개인에게 전달되는 다양한 메시지를 수신하고 저장할 수 있는 강력한 수단을 제공합니다.
이러한 메시지를 자동으로 수집하고 정제하여 내 삶의 결제/소비/이벤트 흐름을 데이터화할 수 있습니다.
저는 이 과정을 통해 수집 → 변형 → 적재 → 전처리 → 분석 → 개인화된 AI 모델 생성까지 이어지는
엔드 투 엔드 데이터 파이프라인을 직접 구축하고, 그 여정을 블로그를 통해 기록하고자 합니다.
작은 수신 메시지 하나가,
나만의 AI model 을 누구나 만들수 있도록 한발 한발 나아가 보겠습니다.
감사합니다.