
발생한 이슈는 버디스탁 앱에서 SMS 문자메시지를 수신받아 백그라운드를 통해 API를 호출하지 못하는 이슈였다. 평소에는 잘 동작했지만, 다건 수신이나 백그라운드 제약 상황에서 간헐적인 누락 의심 케이스가 보이기 시작했다. 문제는 단순 버그 1건이 아니라 실행 백그라운드 수명, 재시도 정책, 동시성 경합, 로그인 전환 상태가 복합적으로 얽혀 있었다는 점이다.
기존 구조는 MySMSReceiver와 SMSHandlerService 모듈의 조합이었다. 수신 트리거와 실제 처리가 분리되어 있다는 점은 나쁘지 않았지만, 실제 운영 관점에서는 실패 이후 복구 보장과 상태 추적이 충분히 선명하지 않았다. 특히 같은 시점에 SMS가 몰리거나 프로세스 상태가 바뀌는 순간에는 작업 단위가 어디까지 보장되는지 명확히 설명하기 어려운 구조였다.
나는 이번 개선에서 즉시성보다 복구 가능성과 일관성을 더 우선으로 두기로 했다. 체결 데이터는 한 번 놓치면 사용자 신뢰가 크게 흔들리는 데이터이기 때문이다. 그래서 핵심 목표를 “빨리 실행”이 아니라 “실패해도 결국 처리”로 다시 정의했다.
처음 고민은
- Service를 유지할지?
- WorkManager로 옮길지?
였다. Service는 백그라운드 작업에 즉시 실행에 좋지만, 최신 안드로이드 백그라운드 정책 아래에서 수명과 제약때문에 지속시간 보장을 못한다.(약 10초) 반면 WorkManager는 OS 정책 친화적이며 재시도, 제약, 영속 작업 관리가 구조적으로 갖춰져 있다. 지연 가능성은 생기지만, 실패 복구와 실행 보장 설계를 단순화할 수 있다.
결론은 Receiver는 트리거만 수행하고, 실제 데이터 수집 이하, ingestion 처리는 WorkManager + Room으로 넘기는 방식을 선택했다. 이 선택은 코드 양이 늘어나긴 하지만 책임 경계를 명확히 하는 선택이었다.
핵심은 Receiver가 Service를 기동하던 구조에서 Receiver가 Repository 트리거를 호출하고, 실제 처리를 Worker + Repository로 이동한 점이다.
/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) }
}
/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)와 서버 워터마크를 함께 사용한다.
- 단말 쪽(=
lastMessageIdProvided): 앱은content://sms를 조회해 SMS row의_ID(msgId)를 읽는다. 이 저장소는 단말 SMS DB(SQLite 기반)를 백엔드로 사용하는 ContentProvider이다.- 서버 쪽(=
serverTradingMsgId): 앱은GET /trading/msgId/{userId}를 호출해 해당 사용자 기준 서버 워터마크(tradingMsgId)를 가져온다.- 조회 기준점: 로컬 조회 시
_ID > baseline조건을 쓰고, baseline은max(serverTradingMsgId, lastMessageIdProvided)로 계산한다.- 업로드: 조회된 최신 메시지 리스트를
POST /trading로 전송한다.- 갱신: 서버는 처리 후 최신 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 상태는 건드리지 않음.)
테스트는 “수신 트리거가 실제 서버 반영까지 이어지는지”를 기준으로 진행했다. 핵심 검증 포인트는 아래 세 가지였다.
실행한 E2E 케이스는 단건/다건/필터링/오프라인 복구/프로세스 상태/권한/스트레스까지 총괄했다.
최종 상태 검증도 함께 수행했다.
즉, 단건/다건/장애/복구를 포함한 현재 테스트 범위에서 핵심 파이프라인(수신 -> 큐 적재 -> worker -> API 호출)은 정상 동작하며, burst 상황에서는 즉시성보다 안정성(백오프 재시도) 우선으로 수렴하는 동작을 확인했다.
이번 작업에서 계속 붙들고 있었던 질문은 네 가지였다.
첫째, Service를 제거하면 즉시성 체감 손실을 얼마나 감수할 수 있는가였다.
둘째, Room 테이블을 최소화하면서도 상태를 충분히 설명할 수 있는가였다.
셋째, 401을 일반 재시도로 돌려도 되는가였다.
넷째, 서버 반영 지연과 앱 동시 수신 경합을 동시에 줄일 수 있는가였다.
결론은 명확했다. 체결 수집은 “언제나 즉시”보다 “결국 정확히 반영”이 더 중요한 문제였다.
이번 작업에서 AI를 코드 생성기보다 설계 검증 파트너로 썼다. 내가 요구한 조건은 단순했다. 기능 100% 유지, 경계 조건 명확화, 테스트 가능성 보장이다.
AI활용을 위해, codex의 plan 모드로 선택지를 여러번 던졌다.
- Service 유지 vs 제거,
- WorkManager 도입 시 희생점,
- Room 최소 스키마,
- 401/429/5xx 분리 정책,
- 계정 전환 경계 보존
같은 질문을 반복적으로 검증했다. 그 과정에서 최종 코드는 더 단순해졌고, 왜 이렇게 동작해야 하는지 설명 가능한 상태가 되었다.
이번 작업의 핵심은 백그라운드 작업의 즉시성과 작업 결과 신뢰성을 동시에 만족하는 실행 경로를 만든 점이다. SMS 수신 직후에는 Receiver에서 Room 테이블에 해당 데이터를 insert 및 Worker빠르게 트리거한다. 그 후, 실패 케이스는 WorkManager 재시도로 넘겨 복구 가능성을 확보했다. 이는 Service 중심 구조에서 생기던 수명/동시성 혼선을 줄이고 책임 경계를 명확히 만든 개선이었다.
Service를 제거하면 구조는 단순해지지만 앱·OS 상태에 따라 즉시 실행 타이밍이 흔들릴 수 있다. WorkManager만 고집하면 안정성은 올라가지만 체감 지연이 생길 수 있다. 그래서 이번에는 즉시 시도 + 실패 시 WorkManager 위임의 하이브리드 전략을 선택한 것이다.
단건, 다건, 스트레스, 실패 재시도, 권한, 계정 전환 케이스를 분리해 검증했다. 핵심 체크 포인트는 누락 없는 전송, 중복 최소화, 재로그인 이후 재개, 백그라운드 복구였다. 결과적으로 구조 변경 이후에도 기존 기능은 유지됐고 장애 상황 복원력은 개선됐다.
다음 단계는 관측 지표를 강화해 운영에서 더 빨리 이상을 감지하는 것이다. 성공률, 평균 지연 시간, 재시도 횟수, 401/429 비중을 수집해 대시보드로 관리해야 한다. 여기까지 붙으면 SMS 기반 거래 동기화는 기능 구현을 넘어 운영 가능한 시스템이 된다.