이 글은 AI와 함께 작성했습니다.

알뜰폰 알닷에는 공식 모바일 앱이 없습니다. 이번 달 데이터가 얼마나 남았는지 보려면 매번 브라우저로 알닷 웹사이트에 들어가 로그인해야 했습니다. 하루에도 몇 번씩 확인하고 싶은 숫자인데, 그때마다 웹사이트를 여는 것이 번거로웠습니다.

그래서 남았닷이라는 안드로이드 위젯 앱을 직접 만들었습니다. 남은 데이터·음성·문자를 홈 화면 위젯으로 바로 보여 주는 앱입니다.

알닷에는 공개 API도 없어서, 앱 안의 WebView로 알닷 웹사이트에 로그인하고 그 쿠키로 사이트가 쓰는 사용량 API를 호출합니다. 문제는 이 로그인이 2시간이면 풀린다는 것이었습니다. 위젯은 사용자가 앱을 열지 않아도 알아서 갱신되어야 하는데, 밤새 폰을 내려놓았다가 아침에 보면 위젯에 “로그인 필요”가 떠 있으면 의미가 없습니다.

이 글은 그 로그인을 밤새 유지하기까지 실제 폰으로 세 번의 밤을 테스트한 기록입니다.

테스트한 기기는 갤럭시 Z 폴드8(Android 17, One UI 9.0) 한 대이고, 내용은 2026년 11월 기준입니다. 절전 동작은 제조사와 OS 버전마다 다르니 감안하고 읽어주세요. 글에 실은 코드는 설명을 위해 실제 코드를 조금 줄인 것입니다.

로그인은 정말 2시간인가

먼저 로그인이 얼마나 유지되는지부터 확인했습니다. 알닷 API는 api.uplusmvno.com의 HttpOnly 쿠키 두 개(accessToken, refreshToken)로만 인증합니다. 액세스 토큰이 만료되면 401 NS009가 오고, 이때 재발급 API를 부르면 두 쿠키가 새로 발급됩니다. 알닷 사이트도 axios 인터셉터로 똑같이 동작합니다.

앱의 WebView 쿠키 저장소에서 두 쿠키의 만료 시각을 읽어 보니 이랬습니다.

accessToken   created=10-29 20:25  expires=10-29 22:25  lifetime=1:59:59
refreshToken  created=10-29 20:25  expires=10-29 22:25  lifetime=1:59:59

토큰 안의 만료 시각(JWT의 exp)도 확인했습니다. 액세스 토큰은 1시간, 리프레시 토큰은 2시간이었습니다. 쿠키만 2시간인 것이 아니라 토큰 자체가 2시간이라, 쿠키를 오래 붙잡아 두는 식의 꼼수는 통하지 않습니다.

반대로 좋은 소식도 있었습니다. 2시간 안에 재발급하면 두 토큰이 모두 새로 발급되면서 다시 2시간이 시작됩니다. 즉 2시간이 지나기 전에 한 번씩 재발급만 해 주면 로그인은 계속 이어집니다.

그래서 처음 계획은 단순했습니다.

  • WorkManager 주기 작업으로 1시간마다 사용량을 조회한다.
  • 조회하다 401 NS009가 오면 재발급하고 다시 조회한다.
  • 갱신 주기 선택지에서 원래 넣으려던 3시간은 뺀다.

1시간마다 돌면 2시간 안에 한 번은 재발급될 테니 충분하다고 생각했습니다.

첫 번째 밤: 2시간 37분

절전 모드(Doze)에서 WorkManager 작업이 밀릴 수 있다는 것은 알고 있었지만, 얼마나 밀리는지는 직접 봐야 했습니다. 시스템 로그(logcat)는 금방 지워져서 밤새 기록을 볼 수 없으므로, 갱신할 때마다 시각과 결과만 앱 전용 파일에 남기게 했습니다.

// 시각과 결과만 남긴다. 사용량·전화번호 같은 개인정보는 넣지 않는다.
// 보는 방법: adb shell run-as <패키지명> cat files/refresh_log.txt
class RefreshLog(context: Context) {
    private val file = File(context.filesDir, "refresh_log.txt")

    suspend fun append(event: String) = withContext(Dispatchers.IO) {
        file.appendText("${LocalDateTime.now().format(TIME)} $event\n")
    }
}

배터리 설정은 기본값 그대로, 충전기를 뺀 채로 하룻밤을 두었습니다. 아침에 본 기록입니다.

10-30 19:55:21 foreground OK
10-30 21:28:04 background OK
10-31 00:05:22 background NEED_LOGIN
10-31 00:06:09 foreground OK          ← 다시 로그인
10-31 01:06:31 background OK
10-31 03:44:24 background NEED_LOGIN
시각실행결과앞 실행과의 간격
21:28자동정상1시간 33분
00:05자동로그인 만료2시간 37분
01:06자동정상1시간
03:44자동로그인 만료2시간 38분

1시간으로 맞춘 작업이 밤에는 2시간 37분씩 밀렸습니다. 21:28에 연장된 로그인은 23:28에 만료됐는데, 다음 작업은 00:05에야 실행됐습니다.

제시간에 돌아도 연장이 안 된다

기록을 보다가 하나 더 알게 된 것이 있습니다. 작업이 제시간에 돌아도 연장이 안 되는 경우가 있었습니다.

재발급은 401 NS009가 왔을 때만 일어나는데, 액세스 토큰의 수명은 1시간입니다. 1시간 주기 작업이 59분이나 60분쯤에 돌면 토큰이 아직 살아 있으므로, 조회만 하고 재발급은 하지 않습니다. 그러면 리프레시 토큰의 2시간은 그대로 흘러갑니다. “1시간마다 도니까 1시간의 여유가 있다”고 생각했지만, 실제로는 그 여유가 거의 없었던 것입니다.

그래서 토큰이 만료되기를 기다리지 않고, 마지막 연장 후 45분이 지났으면 조회 전에 먼저 재발급하도록 바꿨습니다. 토큰이 살아 있을 때 재발급해도 정상적으로 두 토큰이 새로 발급되는 것을 확인했습니다.

object SessionPolicy {
    private const val EXTEND_AFTER = 45 * MINUTE

    fun shouldExtend(lastExtendedAt: Long, now: Long): Boolean =
        // 시계가 뒤로 돌아간 경우(lastExtendedAt > now)도 안전하게 연장한다
        now - lastExtendedAt >= EXTEND_AFTER || lastExtendedAt > now
}

이것으로 “제시간에 돌면 확실히 연장된다”는 보장은 생겼습니다. 하지만 작업이 2시간 37분씩 밀리는 문제는 그대로입니다.

안전망 알람

WorkManager는 절전 모드에서 미뤄지는 것이 정상 동작입니다. 절전 중에도 깨어나려면 알람이 필요합니다. 여러 알람 중에 setAndAllowWhileIdle을 골랐습니다.

  • 절전 중에도 울리고, 폰 전체를 깨우지 않고 이 앱만 잠깐 실행됩니다.
  • 정확한 시각을 보장하지 않는 대신 특별한 권한이 필요 없습니다. 정확한 알람(setExactAndAllowWhileIdle)은 Android 14부터 사용자가 직접 권한을 허용해야 합니다.
  • 시스템이 앱당 약 9분에 한 번으로 제한하지만, 이 앱은 그렇게 자주 울릴 일이 없습니다.

그리고 이 알람을 1시간마다 울리는 것이 아니라 안전망으로만 쓰기로 했습니다. 연장할 때마다 알람을 80~95분 뒤로 다시 맞춥니다. 같은 PendingIntent로 다시 맞추면 이전 알람이 대체되므로, WorkManager가 제때 돌아 연장하는 동안에는 알람이 계속 뒤로 밀려서 울리지 않습니다. 작업이 밀릴 때만 알람이 울려서 재발급 요청 하나만 보냅니다.

class SessionKeepAlive(private val context: Context) {
    private val alarmManager = context.getSystemService(AlarmManager::class.java)

    // 연장할 때마다 다시 부른다. 같은 PendingIntent라 이전 알람은 대체된다.
    fun arm(delayMillis: Long) {
        alarmManager.setAndAllowWhileIdle(
            AlarmManager.ELAPSED_REALTIME_WAKEUP,
            SystemClock.elapsedRealtime() + delayMillis,
            pendingIntent(),
        )
    }
}

class KeepAliveReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // 수신기는 원래 곧바로 끝나야 한다. goAsync로 네트워크 요청이 끝날 때까지 잠시 살려 둔다.
        val pending = goAsync()
        CoroutineScope(Dispatchers.Default).launch {
            try {
                withTimeoutOrNull(30_000) { usageSync.keepSessionAlive() } // 사용량 조회 없이 재발급만
            } finally {
                pending.finish()
            }
        }
    }
}

알람이 울렸을 때 무슨 일이 있었는지 알 수 있도록, 도착 시각과 그때의 네트워크 상태도 기록에 남기게 했습니다.

두 번째 밤: 알람은 울렸지만 인터넷이 없었다

45분 미리 연장과 안전망 알람을 넣고, 배터리 설정은 여전히 기본값으로 두 번째 밤을 보냈습니다.

10-31 20:36:06 extend OK
10-31 22:29:36 alarm fired net=BLOCKED
10-31 22:29:36 alarm ERROR UnknownHostException
10-31 22:29:44 extend OK              ← 8초 뒤 WorkManager 작업이 연장 (만료 약 7분 전)
...
11-01 01:36:37 extend OK
11-01 03:32:32 alarm fired net=BLOCKED
11-01 03:32:32 alarm ERROR UnknownHostException
11-01 03:58:04 alarm fired net=BLOCKED   ← 재시도
11-01 03:58:04 alarm ERROR UnknownHostException
11-01 03:58:07 background NEED_LOGIN

첫 번째 밤보다는 훨씬 나아져서 로그인이 10시간 넘게 유지됐지만, 결국 새벽 3시 반쯤 풀렸습니다. 기록에서 두 가지를 알 수 있었습니다.

첫째, 알람이 늦게 왔습니다. 80~95분 뒤로 맞춘 알람이 실제로는 마지막 연장 후 113~116분 뒤에 도착했습니다(20:36 → 22:29, 01:36 → 03:32). 2시간 만료 직전이라 한 번 실패하면 재시도할 여유가 없습니다.

둘째, 더 큰 문제는 알람이 울려도 인터넷을 쓸 수 없었다는 것입니다. 이날 밤 울린 알람 다섯 번이 모두 net=BLOCKED였고, 알람에서 시도한 연장은 전부 UnknownHostException으로 실패했습니다. 절전 모드는 앱을 깨워 주더라도 네트워크는 열어 주지 않았습니다. 22:29에 살아남은 것도 알람 덕분이 아니라, 8초 뒤 마침 WorkManager 작업이 돌았기 때문이었습니다.

다른 위젯 앱들도 절전 중에는 똑같이 막혀 있을 것입니다. 다만 그런 앱들은 절전 모드가 잠깐씩 풀리는 시간에 몰아서 갱신하면 되고, 우리처럼 2시간이라는 마감이 없을 뿐입니다.

세 번째 밤: 배터리 “제한 없음”

결국 남은 방법은 사용자에게 이 앱을 배터리 최적화 대상에서 빼 달라고 하는 것이었습니다. 갤럭시에서는 ‘설정 → 애플리케이션 → 남았닷 → 배터리’에서 제한 없음을 고르는 것입니다.

안드로이드 문서에는 배터리 최적화 예외를 줘도 작업이 미뤄지는 것까지 막아 주지는 않는다고 되어 있습니다. 그래서 효과가 있을지 확신이 없었는데, 배터리를 “제한 없음”으로 바꾸고 알람을 60~75분으로 앞당긴 세 번째 밤의 기록은 이랬습니다.

11-01 19:03:31 extend OK
11-01 20:14:39 alarm fired net=CONNECTED
11-01 20:14:39 alarm extend OK
11-01 21:05 ~ 11-02 01:08  매시간 extend OK
11-02 02:30:03 alarm fired net=CONNECTED
11-02 02:30:03 alarm extend OK
...
11-02 07:26:56 alarm fired net=CONNECTED
11-02 07:26:56 alarm extend OK
11-02 08:28 ~ 18:48  매시간 extend OK

알람 다섯 번이 모두 net=CONNECTED였고 연장도 모두 성공했습니다. 연장 사이 간격은 가장 길 때도 82분이었고, 로그인은 24시간 넘게 유지됐습니다. 예외는 작업이 밀리는 것을 막아 주지는 않았지만, 알람이 울렸을 때 네트워크를 쓸 수 있게 해 주었습니다. 그 사이 남았닷이 쓴 배터리는 약 20시간 동안 2.69mAh로, 폰 전체 배터리 사용량의 0.15% 정도였습니다.

알람 시각을 60~75분으로 앞당긴 이유는 두 번째 밤에 본 지연 때문입니다. 알람이 30~40분 늦게 오더라도 2시간 안에 한 번 더 재시도할 여유를 두려는 것입니다. 시각에 범위를 둔 것은 여러 사용자의 요청이 같은 시각에 몰리지 않게 하기 위해서입니다.

배터리 예외를 요청하는 방법

배터리 “제한 없음”은 사용자가 직접 바꿔야 하는 설정이라, 설정 화면 위치만 안내하면 대부분 건너뛸 것입니다. 안드로이드에는 이 앱을 예외로 둘지 바로 묻는 시스템 창(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)이 있어서, 첫 실행 안내에서 이 창을 띄우도록 했습니다.

val request = Intent(
    Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
    Uri.parse("package:${context.packageName}"),
)
context.startActivity(request)

다만 Google Play는 이 창을 쓸 수 있는 앱의 종류를 정책으로 제한합니다. 남았닷은 Play 스토어가 아니라 GitHub에서 APK로 배포하므로 이 창을 쓰기로 했고, 나중에 스토어에 올리게 되면 설정 화면으로 안내하는 방식으로 바꿀 수 있게 플래그로 분리해 두었습니다.

갤럭시는 이 창의 문구가 기본 안드로이드와 다르게 “배터리 사용량 최적화 중지”이고 버튼도 취소/확인입니다. 그래서 첫 실행 안내에서 삼성 폰이면 이 창의 모양을 미리 보여 주고 “‘확인’을 눌러 주세요”라고 안내합니다. 처음에는 닫을 수 있는 안내 카드였지만, 이 설정 없이는 밤에 로그인이 풀린다는 것을 확인한 뒤로는 설정될 때까지 계속 보이는 배너로 바꿨습니다.

남은 한계

  • 테스트는 한 기기, 하룻밤씩입니다. 갤럭시 Z 폴드8 한 대로만 확인했고, 다른 제조사나 One UI 버전에서는 결과가 다를 수 있습니다.
  • 사용자의 설정에 기대는 방법입니다. 배터리 “제한 없음”을 거절하면 밤사이 로그인이 풀릴 수 있습니다. 이 경우 로그인 만료 알림을 한 번 보내서 다시 로그인하도록 안내합니다.
  • 사이트의 의도와는 어긋날 수 있습니다. 2시간 자동 로그아웃은 알닷이 정한 보안 정책일 수 있습니다. 남았닷은 사이트와 같은 재발급 API를 정해진 간격으로 부를 뿐이지만, 비공식 앱인 만큼 알닷이 방식을 바꾸면 언제든 동작하지 않을 수 있다는 점을 README에 밝혀 두었습니다.

마치며

정리하면, 2시간짜리 로그인을 밤새 유지하기 위해 이렇게 겹겹이 쌓았습니다.

장치역할혼자서는 부족했던 이유
WorkManager 1시간 주기평소의 조회와 연장밤에는 2시간 37분까지 밀림
45분 지나면 미리 연장제시간에 돌면 확실히 연장작업이 밀리는 것은 못 막음
setAndAllowWhileIdle 안전망 알람 (60~75분)작업이 밀릴 때 깨어나 연장절전 중에는 네트워크가 막힘
배터리 “제한 없음”알람 때 네트워크를 쓸 수 있게사용자가 허용해야 함

처음에는 WorkManager 하나면 충분할 줄 알았는데, 매번 다음 문제가 하나씩 더 나왔습니다. 그리고 각 단계의 원인은 하룻밤 동안의 기록 파일이 없었다면 찾지 못했을 것입니다. “알람이 안 울렸다”와 “알람은 울렸는데 네트워크가 없었다”는 결과는 똑같이 로그인 만료지만, 해결 방법은 완전히 다르기 때문입니다.

혹시 백그라운드에서 마감이 있는 작업을 해야 하는 앱을 만든다면, adb로 절전 모드를 강제로 거는 시험만 믿지 말고 실제 폰을 충전기에서 뽑아 하룻밤 두고 기록을 남겨 보시는 것을 추천드립니다.