BroadcastReceiver의 SMS 백그라운드 수집을 Service에서 WorkManager로 마이그레이션 하기

SSY·2026년 3월 14일

AndroidFramework

목록 보기
6/8
post-thumbnail

시작하며

발생한 이슈는 버디스탁 앱에서 SMS 문자메시지를 수신받아 백그라운드를 통해 API를 호출하지 못하는 이슈였다. 평소에는 잘 동작했지만, 다건 수신이나 백그라운드 제약 상황에서 간헐적인 누락 의심 케이스가 보이기 시작했다. 문제는 단순 버그 1건이 아니라 실행 백그라운드 수명, 재시도 정책, 동시성 경합, 로그인 전환 상태가 복합적으로 얽혀 있었다는 점이다.

기존 구조는 MySMSReceiverSMSHandlerService 모듈의 조합이었다. 수신 트리거와 실제 처리가 분리되어 있다는 점은 나쁘지 않았지만, 실제 운영 관점에서는 실패 이후 복구 보장과 상태 추적이 충분히 선명하지 않았다. 특히 같은 시점에 SMS가 몰리거나 프로세스 상태가 바뀌는 순간에는 작업 단위가 어디까지 보장되는지 명확히 설명하기 어려운 구조였다.

나는 이번 개선에서 즉시성보다 복구 가능성과 일관성을 더 우선으로 두기로 했다. 체결 데이터는 한 번 놓치면 사용자 신뢰가 크게 흔들리는 데이터이기 때문이다. 그래서 핵심 목표를 “빨리 실행”이 아니라 “실패해도 결국 처리”로 다시 정의했다.

왜 Service를 덜어냈는가

처음 고민은

  • Service를 유지할지?
  • WorkManager로 옮길지?

였다. Service는 백그라운드 작업에 즉시 실행에 좋지만, 최신 안드로이드 백그라운드 정책 아래에서 수명과 제약때문에 지속시간 보장을 못한다.(약 10초) 반면 WorkManager는 OS 정책 친화적이며 재시도, 제약, 영속 작업 관리가 구조적으로 갖춰져 있다. 지연 가능성은 생기지만, 실패 복구와 실행 보장 설계를 단순화할 수 있다.

결론은 Receiver는 트리거만 수행하고, 실제 데이터 수집 이하, ingestion 처리는 WorkManager + Room으로 넘기는 방식을 선택했다. 이 선택은 코드 양이 늘어나긴 하지만 책임 경계를 명확히 하는 선택이었다.

before/after 소스코드 비교

핵심은 Receiver가 Service를 기동하던 구조에서 Receiver가 Repository 트리거를 호출하고, 실제 처리를 Worker + Repository로 이동한 점이다.

Before: Receiver -> Service 기동

/MySMSReceiver.kt

if (getPrefsBooleanUseCase(Pair(SP_MAIN_SMS_AGREE, false)).successOr(false)) {
    context.startService(Intent(context, SMSHandlerService::class.java).apply {
        putExtra(SMSHandlerService.USER_ID, userId)
        putExtra(SMSHandlerService.SENDER, sender)
    })
}

/SMSHandlerService.kt

private val workItems: Queue<WorkItem> = LinkedList()
private var workerThread: Thread? = null

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    workItems.add(WorkItem(userId, 0, sender))
    if (workerThread?.isInterrupted == false) workerThread?.interrupt()
    return super.onStartCommand(intent, flags, startId)
}

private fun workerTask() {
    if (workItems.isEmpty()) {
        Thread.sleep(intervalThreadPoll.get())
        slowDownExponentially()
        return
    }
    val workItem = workItems.poll() ?: return
    CoroutineScope(Dispatchers.IO).launch { dataReceivedOrTimeout(workItem) }
}

After: Receiver -> Repository 트리거, Worker가 배치 처리

/MySMSReceiver.kt

tradingIngestionRepository.enqueueTrigger(
    userId = userId,
    senderPhoneNumber = sender ?: return@launch
)

/SmsTradingRetryWorker.kt

override suspend fun doWork(): Result {
    val hasMore = tradingIngestionRepository.processRetryBatch(
        maxBatchSize = DEFAULT_MAX_BATCH_SIZE
    )
    return if (hasMore) Result.retry() else Result.success()
}

/TradingIngestionRepositoryImpl.kt

private suspend fun processTask(pendingTask: SmsTradingIngestionQueueEntity): ProcessTaskResult {
    return processMutex.withLock {
        runCatching {
            val serverMsgId = tradingRepository.getTradingMsgId(pendingTask.userId.toInt()).toLong()
            val messages = queryMessagesWithRetry(
                userId = pendingTask.userId,
                serverMsgId = serverMsgId,
                sender = pendingTask.senderPhoneNumber
            )
            tradingRepository.updateTrading(dto = UpdateTradingNoPushRequestDto(messages))
            ProcessTaskResult.Success
        }.getOrElse { throwable ->
            ProcessTaskResult.Failure(throwable)
        }
    }
}

최종 구조

이 구조에서 중요한 점은 Receiver가 무거운 처리를 하지 않는다는 점이다. Receiver는 짧은 수명 안에서 검증과 큐 적재 요청까지만 수행한다. 처리 본문은 Repository와 Worker로 옮겨져서 상태를 기록하면서 재시도할 수 있게 된다.

동시 수신 경합을 어떻게 줄였는가

여기서 이해를 위한 배경지식이 필요하다. 이 파이프라인은 단말 로컬 SMS 저장소(ContentProvider)와 서버 워터마크를 함께 사용한다.

  1. 단말 쪽(=lastMessageIdProvided): 앱은 content://sms를 조회해 SMS row의 _ID(msgId)를 읽는다. 이 저장소는 단말 SMS DB(SQLite 기반)를 백엔드로 사용하는 ContentProvider이다.
  2. 서버 쪽(=serverTradingMsgId): 앱은 GET /trading/msgId/{userId}를 호출해 해당 사용자 기준 서버 워터마크(tradingMsgId)를 가져온다.
  3. 조회 기준점: 로컬 조회 시 _ID > baseline 조건을 쓰고, baseline은 max(serverTradingMsgId, lastMessageIdProvided)로 계산한다.
  4. 업로드: 조회된 최신 메시지 리스트를 POST /trading로 전송한다.
  5. 갱신: 서버는 처리 후 최신 msgId를 워터마크로 저장하고, 다음 GET /trading/msgId/{userId} 응답에 반영한다.

BroadcastReceiver를 통한 SMS 메시지를 다건 수신 상황에서 가장 위험한 지점은 같은 기준점으로 여러 작업이 동시에 조회/업로드를 시도하는 순간이다. 나는 이 지점을 완화하기 위해 두 가지 장치를 함께 사용했다.

첫째는 processMutex이다. getTradingMsgId -> SMS 조회 -> updateTrading 구간을 트랜잭션으로 묶어 RaceCondition 가능성을 제거한다.

둘째는 lastMessageIdProvided이다. 서버 반영이 순간적으로 늦어지면 서버 기준 msgId가 뒤처질 수 있다. 이때 앱이 이미 보낸 최신 msgId를 함께 기준점으로 사용해 조회 범위가 뒤로 밀리는 현상을 보정한다.

둘을 같이 써야 경합과 반영 지연을 함께 다룰 수 있다고 판단했다.

실패 정책을 세 갈래로 분리한 이유

이전에는 실패를 하나로 취급하기 쉬웠다. 하지만 401, 429, 5xx는 성격이 전혀 다르다.

  • 401은 인증 상태 문제라서 즉시 재시도해도 의미가 없다
  • 429는 서버 과부하/요청 제한이므로 간격 재시도가 필요하다
  • 5xx/네트워크 오류는 복구 가능 오류로 보는 편이 맞다

그래서 정책을 Retry, Drop, Blocked로 분리했다. 401은 auth block Room 테이블에 기록해 두고, 동일 사용자가 다시 로그인하면 해당 테이블에 block을 해제하고 재개하도록 구성했다.

로그인 전환 시점에서 보존한 원칙

이번 작업에서 신경쓴 부분은 1개의 기기에서 여러 계정으로 로그인할 수 있다는 점이다. 즉
“동일한 userId의 경계를 보존하는 것”이다. 이전 계정(eg., userId=1234)의 펜딩 큐 상태가 다음 계정 처리(eg., userId=1111)에 섞이면 안 된다. 그래서 대기 큐 조회/처리와 auth block 해제 모두 userId 기준으로만 동작하게 정리했다.

즉 로그인 전환 시점의 핵심은 “올바른 userId 경계 + 올바른 msgId 기준점”을 동시에 보장하는 것이다. 이 두 축이 맞아야 누락/중복 없이 최신화된 msgId 기반으로 /updateTrading이 수행된다.

정리하면, 로그인 성공 시에는 해당 userId의 block만 해제하고 retry worker를 재개한다. 이어서 getTradingMsgId -> queryMessages(content://sms) -> updateTradingNoPush 순서로 트랜잭션 동기화를 수행한다. (다른 userId 상태는 건드리지 않음.)

테스트는 어떻게 가져갔는가

테스트는 “수신 트리거가 실제 서버 반영까지 이어지는지”를 기준으로 진행했다. 핵심 검증 포인트는 아래 세 가지였다.

  1. 수신 후 API 호출 체인 정상 여부
  2. 실패 상황(네트워크/권한/프로세스 상태)에서 retry 및 복구 여부
  3. 최종적으로 큐가 소진되어 일관된 종료 상태에 도달하는지

실행한 E2E 케이스는 단건/다건/필터링/오프라인 복구/프로세스 상태/권한/스트레스까지 총괄했다.

  • TC-01 단건(백그라운드): PASS
    • smsReceiver=1, getMsgIdReq=1, postTradingReq=1, workerSuccess=1
  • TC-02 다건 5건 연속: PASS
    • smsReceiver=5, getMsgIdReq=2, postTradingReq=2, workerRetry=1 -> workerSuccess=1
    • burst 상황에서 재시도 후 성공으로 수렴함을 확인했다.
  • TC-03 비허용 발신자: PASS
    • 수신/조회/전송 모두 0으로 필터링 정상 동작 확인.
  • TC-04 오프라인 -> 온라인 복구: PASS
    • 오프라인 구간에서 queue_after_offline=1, 복구 후 postTradingReq=1, queue_after_recovery=0
    • NetworkType.CONNECTED 제약 기반 지연 처리/복구가 의도대로 동작했다.
  • TC-05 프로세스 kill 후 수신: PASS
    • kill 이후에도 수신/업로드 경로 유지 확인.
  • TC-06 force-stop 후 수신: OBSERVED
    • 본 에뮬레이터에서는 처리되었고(smsReceiver=1, postTradingReq=1), 기기/OS 정책 차이는 별도 재검증 포인트로 남겼다.
  • TC-07 혼합 발신자(허용+비허용): PASS
    • 허용 발신자 건만 ingestion(smsReceiver=2, postTradingReq=2).
  • TC-08 권한 시나리오(READ/RECEIVE_SMS): PASS
    • RECEIVE 거부 시 수신 자체 차단.
    • READ 거부 시 Provider 조회 실패로 retry 경로 진입(securityException, workerRetry 증가).
    • READ 복구 후 큐 재처리 성공(workerSuccess=1, queue_count=0).
  • TC-09 스트레스 20건: PASS(단계형 수렴)
    • 초기엔 즉시 소진되지 않고 backoff/retry 구간을 거친 뒤 최종 성공.
    • 후속 실행에서 postTradingReq와 workerSuccess가 확인되고 queue_count=0으로 수렴했다.

최종 상태 검증도 함께 수행했다.

  • sms_trading_ingestion_queue = 0
  • sms_trading_ingestion_auth_blocks = 0
  • READ_SMS = granted, RECEIVE_SMS = granted

즉, 단건/다건/장애/복구를 포함한 현재 테스트 범위에서 핵심 파이프라인(수신 -> 큐 적재 -> worker -> API 호출)은 정상 동작하며, burst 상황에서는 즉시성보다 안정성(백오프 재시도) 우선으로 수렴하는 동작을 확인했다.

이번에 실제로 고민했던 질문

이번 작업에서 계속 붙들고 있었던 질문은 네 가지였다.

첫째, Service를 제거하면 즉시성 체감 손실을 얼마나 감수할 수 있는가였다.
둘째, Room 테이블을 최소화하면서도 상태를 충분히 설명할 수 있는가였다.
셋째, 401을 일반 재시도로 돌려도 되는가였다.
넷째, 서버 반영 지연과 앱 동시 수신 경합을 동시에 줄일 수 있는가였다.

결론은 명확했다. 체결 수집은 “언제나 즉시”보다 “결국 정확히 반영”이 더 중요한 문제였다.

AI를 어떻게 활용했는가

이번 작업에서 AI를 코드 생성기보다 설계 검증 파트너로 썼다. 내가 요구한 조건은 단순했다. 기능 100% 유지, 경계 조건 명확화, 테스트 가능성 보장이다.

AI활용을 위해, codex의 plan 모드로 선택지를 여러번 던졌다.

  • Service 유지 vs 제거,
  • WorkManager 도입 시 희생점,
  • Room 최소 스키마,
  • 401/429/5xx 분리 정책,
  • 계정 전환 경계 보존

같은 질문을 반복적으로 검증했다. 그 과정에서 최종 코드는 더 단순해졌고, 왜 이렇게 동작해야 하는지 설명 가능한 상태가 되었다.

마무리

이번 작업의 핵심은 백그라운드 작업의 즉시성과 작업 결과 신뢰성을 동시에 만족하는 실행 경로를 만든 점이다. SMS 수신 직후에는 Receiver에서 Room 테이블에 해당 데이터를 insert 및 Worker빠르게 트리거한다. 그 후, 실패 케이스는 WorkManager 재시도로 넘겨 복구 가능성을 확보했다. 이는 Service 중심 구조에서 생기던 수명/동시성 혼선을 줄이고 책임 경계를 명확히 만든 개선이었다.

4-1. 기술 선택의 트레이드오프

Service를 제거하면 구조는 단순해지지만 앱·OS 상태에 따라 즉시 실행 타이밍이 흔들릴 수 있다. WorkManager만 고집하면 안정성은 올라가지만 체감 지연이 생길 수 있다. 그래서 이번에는 즉시 시도 + 실패 시 WorkManager 위임의 하이브리드 전략을 선택한 것이다.

4-3. 테스트로 확인한 신뢰성

단건, 다건, 스트레스, 실패 재시도, 권한, 계정 전환 케이스를 분리해 검증했다. 핵심 체크 포인트는 누락 없는 전송, 중복 최소화, 재로그인 이후 재개, 백그라운드 복구였다. 결과적으로 구조 변경 이후에도 기존 기능은 유지됐고 장애 상황 복원력은 개선됐다.

4-4. 다음 단계

다음 단계는 관측 지표를 강화해 운영에서 더 빨리 이상을 감지하는 것이다. 성공률, 평균 지연 시간, 재시도 횟수, 401/429 비중을 수집해 대시보드로 관리해야 한다. 여기까지 붙으면 SMS 기반 거래 동기화는 기능 구현을 넘어 운영 가능한 시스템이 된다.

0개의 댓글