AccessibilityService 없이 Android 앱 차단 앱 만들기

UsageStatsManager, 포그라운드 서비스, 오버레이. 실제로 출시한 앱 이야기예요. 글: Thomas Willheim · 2026년 11월 9일

저는 팔굽혀펴기를 해서 스크린 타임을 버는 앱 차단 앱 HonorFit을 만들어요(양심 시스템, 카메라 없음). 첫 Android 버전은 대부분의 Android 차단 앱처럼 AccessibilityService로 앱을 차단했어요. 8월에 그걸 통째로 뜯어내고 UsageStatsManager, 포그라운드 서비스, 오버레이 창으로 바꿨어요. 차단은 조금 느슨해졌지만, 온보딩은 훨씬 덜 무서워졌고 Play 심사도 훨씬 단순해졌어요.

이 글은 그 구조가 어떻게 생겼는지, 그리고 그 과정에서 제가 당한 것들에 대한 이야기예요. 코드는 전부 로컬 Expo 모듈 안의 Kotlin이지만, React Native에만 해당하는 내용은 하나도 없어요.

접근성 API를 버린 이유

접근성 서비스는 Android에서 쓸 수 있는 가장 강력한 도구예요. 창이 바뀌는 순간 바로 알 수 있고, performGlobalAction(GLOBAL_ACTION_HOME)으로 차단한 앱에서 사용자를 그 자리에서 쫓아낼 수 있어요. 그래서 많은 차단 앱이 이걸 써요. 대가는 이래요.

그 사실 하나는 다른 데서도 얻을 수 있어요. 디지털 웰빙 뒤에 있는 사용 이벤트예요.

전체 구조

권한은 세 가지이고, 모두 사용자가 설정에서 허용해요.

권한용도
PACKAGE_USAGE_STATS (‘사용 기록 액세스’)UsageEvents를 읽어서 어떤 앱이 앞에 있는지 알아내요. 런타임에 허용되는 권한이 아니라서, 사용자를 설정 화면으로 보내고 app-op를 확인해요.
SYSTEM_ALERT_WINDOW (‘다른 앱 위에 표시’)차단한 앱 위에 잠금 화면을 그려요.
FOREGROUND_SERVICE_SPECIAL_USE모니터 서비스용이에요. 앱 차단은 정해진 포그라운드 서비스 유형 어디에도 맞지 않아서, specialUse로 하고 이유를 설명하는 서브타입 문자열을 붙여요.

구성 요소는 이래요.

서비스의 매니페스트 항목이에요.

<service
  android:name=".ForegroundMonitorService"
  android:exported="false"
  android:foregroundServiceType="specialUse">
  <property
    android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
    android:value="User-configured app blocking for digital wellbeing: monitors
      which app is in the foreground (UsageStats) so apps the user chose to block
      can be locked when their earned screen time runs out." />
</service>

Play Console의 포그라운드 서비스 신고에서도 specialUse를 정당화해야 해요. 두 군데에서 같은 이야기를 하면 도움이 돼요.

UsageStats 폴링(과 쓰기 지연)

핵심 루프는 작아요. 마지막 폴링 이후의 이벤트를 훑고, 마지막 MOVE_TO_FOREGROUND의 패키지를 기억해 둬요(ACTIVITY_RESUMED와 같은 상수인데, API 29에서 이름이 바뀌었어요).

val end = System.currentTimeMillis()
val events = usm.queryEvents(windowStart, end)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
  events.getNextEvent(event)
  if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
    currentForeground = event.packageName
  }
}
// Re-scan the last 15 s next time: see below.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)

접근성 이벤트에 비해 좋은 부수 효과도 있어요. 키보드와 알림 창은 액티비티가 아니라서, ‘사용자가 앱을 나갔다’는 가짜 이탈로 잡히지 않아요.

함정: 이벤트가 늦게 도착해요

첫 버전에서는 창을 엄격하게 앞으로만 옮겼어요. 폴링할 때마다 [lastEnd, now]를 조회했죠. 대체로 잘 됐는데, 콜드 스타트한 폰에서 차단 앱을 처음 연 게 전혀 잡히지 않았어요. UsageStats는 이벤트를 저장소에 늦게 써요. 그래서 10:00:00.900이 찍힌 이벤트가 이미 10:00:01까지 훑은 폴링 뒤에 나타날 수 있어요. 엄격하게 앞으로만 가는 창은 그 이벤트를 영원히 건너뛰어요.

해결책: 폴링할 때마다 바로 앞 구간(저는 15초)을 겹쳐서 다시 훑어요. 같은 이벤트를 두 번 봐도 문제없어요. 어느 쪽이든 창 안의 마지막 resume이 이기니까요. 15초보다 더 늦게 도착한 이벤트는 여전히 놓치지만, 아직 그런 건 못 봤어요.

함정: 첫 폴링은 멀리 거슬러 봐야 해요

서비스가 시작될 때(재시작 후나 화면이 켜질 때)는 ‘마지막으로 알던 앱’이 없어요. 사용자가 틱톡에 20분째 있는데 첫 폴링이 1분만 거슬러 보면, 앱을 바꿀 때까지 아무것도 안 보여요. 그래서 첫 폴링은 지난 1시간을 훑어요. 런처도 액티비티라서 이렇게 해도 안전해요. 마지막으로 resume된 게 정말로 앞에 있는 앱이에요.

함정: 권한을 끄면 그냥 조용해 보여요

서비스가 도는 중에 사용자가 사용 기록 액세스를 끄면, queryEvents는 예외를 던지지 않아요. 그냥 아무것도 안 돌려줘요. 마지막으로 읽은 값(‘인스타그램이 앞에 있음’)이 낡은 채로 남고, 잔액은 끝없이 깎여요. 그래서 폴링할 때마다 먼저 app-op를 확인하고, 권한이 없어졌으면 시계를 멈춰요.

fun hasUsageAccess(context: Context): Boolean {
  val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
  val mode = appOps.checkOpNoThrow(
    AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
  return mode == AppOpsManager.MODE_ALLOWED
}

화면이 꺼지면 멈추기

화면이 꺼지면 서비스는 열려 있던 세션을 정산하고 폴링을 완전히 멈춰요. 화면을 끈 채로 앱을 쓰는 사람은 없고, 폰을 보고 있을 때만 도는 1 Hz 루프는 부담이 작아요. 화면이 다시 켜진 뒤 첫 폴링에서는 날짜 넘어가기(자정 초기화, 기본 시간)도 처리해요. 그래서 HonorFit을 한 번도 열지 않더라도, 사용자가 차단한 앱에 닿기 전에 처리가 끝나 있어요.

잠금 오버레이

접근성이 없으면 GLOBAL_ACTION_HOME도 없으니까, 사용자를 앱에서 쫓아낼 수 없어요. 대신 덮어요. ‘시간 더 얻기’ 버튼과 ‘홈으로 가기’ 버튼이 있는 TYPE_APPLICATION_OVERLAY 창이에요.

서비스 살려 두기

제대로 하기까지 제일 오래 걸리는 부분이고, 대부분은 내 코드의 문제가 아니에요.

START_STICKY로는 부족해요

삼성의 ‘절전 상태 앱’, MIUI, OnePlus 같은 것들은 백그라운드 서비스를 죽이고, sticky 재시작까지 삼켜 버려요. 차단 앱한테는 최악의 경우예요. 시간이 남아 있는 동안에는 아무도 앱을 열지 않으니까, 모니터가 사라진 걸 아무도 눈치채지 못해요. HonorFit은 15분마다 서비스를 다시 세우는, inexact하고 non-wakeup인 반복 알람을 걸어 둬요. 폰을 깨우는 일은 없고, 화면이 꺼져 있는 동안 미뤄진 알람은 화면이 다시 켜지면 곧 울려요. 모니터가 중요한 때가 바로 그때예요.

Android 12 이상에서는 시작을 못 하게 할 때가 있어요

백그라운드(알람, sticky 재시작)에서 포그라운드 서비스를 시작하면, 예외 조건에 해당하지 않는 한 ForegroundServiceStartNotAllowedException이 던져져요. 여기서 중요한 예외는 두 가지예요. 사용자가 앱의 배터리 최적화를 껐거나, 앱이 SYSTEM_ALERT_WINDOW를 가지고 있는 경우(이건 Android 15 전까지만 통해요). 그래서 이렇게 해요.

재부팅과 업데이트

BOOT_COMPLETED와 MY_PACKAGE_REPLACED 리시버가 모니터링을 다시 시작해요. 두 번째는 잊기 쉬워요. Play 자동 업데이트는 프로세스를 죽이고 서비스를 다시 시작해 주지 않아요. 그래서 이게 없으면 업데이트할 때마다, 사용자가 앱을 열 때까지 차단 앱이 조용히 꺼져 있어요. 두 브로드캐스트 모두 백그라운드 시작 제한에서 예외이고, exported="false" 리시버도 받을 수 있어요.

자잘한 것들

트레이드오프

이 방식은 접근성보다 느슨해요. 이 점은 솔직하게 말해 두고 싶어요.

양심 시스템 차단 앱이라면 이게 맞는 거래예요. HonorFit은 절대 뚫리지 않는 걸 목표로 하지 않아요. 작정하고 속이려는 사람은 하지도 않은 운동을 기록하면 그만이에요. 핵심은 잠깐 멈추게 하고 대가를 치르게 하는 거지, 감옥에 가두는 게 아니에요. 그 대신 ‘전체 제어’가 아니라 ‘사용 기록 액세스’라고 적힌 권한 화면, 훨씬 단순한 Play 심사, 그리고 앞에 있는 앱의 패키지 이름이라는 딱 한 가지만 읽는 서비스를 얻어요.

iOS도 만들고 있다면, 거기는 완전히 다른 세상이에요. Apple의 스크린 타임 문서가 알려 주지 않는 것들을 정리해 뒀어요.

HonorFit Blocker는 iPhone과 Android용 무료 앱 차단 앱이에요. 푸시업, 스쿼트, 직접 고른 아무 운동으로나 스크린 타임을 벌어요. 양심 시스템, 카메라 없음, 계정 없음, 모든 데이터는 내 폰 안에만 있어요.

계속 읽기