Tuesday, June 13, 2023

안드로이드 앱 데이터 사용량의 정밀 측정: NetworkStatsManager 활용법

스마트폰 사용자에게 데이터 사용량은 매우 중요한 관리 대상입니다. 데이터를 효율적으로 관리하고 예상치 못한 요금 발생을 방지하기 위해, 많은 애플리케이션은 사용자에게 앱별 데이터 사용량 정보를 제공합니다. 안드로이드 개발자라면 이러한 기능을 구현하기 위해 시스템이 제공하는 강력한 도구인 NetworkStatsManager를 활용하는 방법을 알아야 합니다. 이 글에서는 코틀린을 사용하여 특정 애플리케이션의 데이터 사용량을 정확하게 측정하는 전체 과정을 심도 있게 다룹니다. 단순히 코드를 나열하는 것을 넘어, 필요한 권한 획득부터 API의 동작 원리, 데이터 처리 및 고급 활용 기법까지 상세하게 살펴보겠습니다.

핵심 도구: NetworkStatsManager란 무엇인가?

NetworkStatsManager는 안드로이드 M (API 레벨 23)부터 도입된 시스템 서비스 클래스입니다. 이 클래스는 기기의 네트워크 트래픽 사용량 통계에 대한 접근을 제공합니다. 과거에는 TrafficStats 클래스를 사용했지만, 이는 기기가 부팅된 이후의 누적 사용량만 제공하여 특정 기간의 사용량을 측정하는 데 한계가 있었습니다. NetworkStatsManager는 이러한 단점을 보완하여, 지정된 시간 간격, 네트워크 유형(Wi-Fi, 모바일), 그리고 개별 애플리케이션(UID 기준)에 대한 상세한 네트워크 사용량 데이터를 조회할 수 있는 강력한 기능을 제공합니다.

이 API를 통해 개발자는 다음과 같은 정교한 기능을 구현할 수 있습니다.

  • 특정 기간(예: 일간, 주간, 월간) 동안 각 앱이 사용한 모바일 데이터 및 Wi-Fi 데이터 양 측정
  • 앱이 포그라운드에서 실행될 때와 백그라운드에서 실행될 때의 데이터 사용량 분리 측정
  • 듀얼 SIM 기기에서 각 SIM 카드의 데이터 사용량 개별 추적
  • 전체 기기의 총 데이터 사용량 집계

이처럼 강력한 기능을 제공하는 만큼, 사용자의 민감한 정보에 접근하는 것이므로 특별한 권한이 필요합니다.

1단계: 필수 권한 획득 및 설정

NetworkStatsManager를 사용하여 네트워크 사용량 통계를 조회하려면, 앱이 PACKAGE_USAGE_STATS라는 특별한 권한을 가지고 있어야 합니다. 이 권한은 일반적인 '위험 권한'과 달리, 사용자가 런타임에 대화상자를 통해 수락하는 방식이 아닙니다. 대신, 사용자가 직접 시스템 설정 화면으로 이동하여 앱에 대한 '사용 정보 접근'을 허용해주어야 합니다.

AndroidManifest.xml에 권한 선언

가장 먼저, AndroidManifest.xml 파일에 다음과 같이 권한을 선언해야 합니다. 이 선언은 시스템에 우리 앱이 해당 권한을 사용하고자 함을 알리는 역할을 합니다.


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.datastatsexample">

    <!-- 네트워크 사용량 통계 접근 권한 -->
    <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
        tools:ignore="ProtectedPermissions" />
    
    <application ... >
        ...
    </application>

</manifest>

tools:ignore="ProtectedPermissions" 속성은 이 권한이 시스템 앱이나 서명된 앱에만 부여되는 보호된 권한이라는 Lint 경고를 무시하기 위해 추가되었습니다. 일반 앱도 사용자 동의를 통해 이 권한을 획득할 수 있으므로, 이 경고는 무시해도 안전합니다.

사용자에게 권한 요청하기

권한이 선언되었다고 해서 바로 사용할 수 있는 것은 아닙니다. 앱 실행 시점에 권한이 부여되었는지 확인하고, 부여되지 않았다면 사용자에게 권한을 요청하는 코드를 구현해야 합니다.

먼저 현재 앱에 권한이 부여되었는지 확인하는 함수를 작성합니다.


import android.app.AppOpsManager
import android.content.Context
import android.os.Process

private fun hasUsageStatsPermission(context: Context): Boolean {
    val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    // AppOpsManager.OPSTR_GET_USAGE_STATS는 API 29부터 사용 가능한 문자열 상수입니다.
    // 하위 호환성을 위해 "android:get_usage_stats" 문자열을 직접 사용할 수 있습니다.
    val mode = appOpsManager.checkOpNoThrow(
        "android:get_usage_stats",
        Process.myUid(),
        context.packageName
    )
    return mode == AppOpsManager.MODE_ALLOWED
}

이 함수는 AppOpsManager를 사용하여 현재 앱의 get_usage_stats 작업이 허용(MODE_ALLOWED) 상태인지 확인합니다. 이 확인 로직을 사용하여 권한이 없을 경우, 사용자에게 설정 화면으로 이동하여 권한을 활성화하도록 안내해야 합니다.


import android.content.Intent
import android.provider.Settings

fun requestUsageStatsPermission(context: Context) {
    // 권한이 이미 있는지 확인
    if (!hasUsageStatsPermission(context)) {
        // 권한이 없다면 설정 화면으로 이동하는 인텐트 생성
        val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
        context.startActivity(intent)
    }
}

Activity나 Fragment의 onCreate 또는 특정 기능 실행 시점에 requestUsageStatsPermission(this)와 같이 호출하여 권한 상태를 확인하고 필요시 사용자에게 요청할 수 있습니다. 사용자가 설정 화면에서 권한을 부여하고 앱으로 돌아왔을 때, 다시 한 번 권한 상태를 확인하여 기능 활성화 여부를 결정해야 합니다.

2단계: 데이터 사용량 측정 로직 구현

권한 문제가 해결되었다면, 이제 본격적으로 NetworkStatsManager를 사용하여 데이터를 조회하는 코드를 작성할 차례입니다. 전체 과정은 크게 (1) UID 획득, (2) 데이터 쿼리, (3) 결과 처리의 세 단계로 나눌 수 있습니다.

애플리케이션의 고유 식별자(UID) 찾기

안드로이드 시스템은 각 애플리케이션을 패키지 이름이 아닌 UID(User ID)라는 고유한 정수 값으로 식별합니다. 따라서 특정 패키지 이름(예: "com.google.android.youtube")의 데이터 사용량을 조회하려면, 먼저 해당 패키지 이름에 할당된 UID를 알아내야 합니다.

PackageManager를 사용하면 패키지 이름으로부터 UID를 손쉽게 얻을 수 있습니다. 원문에서는 getInstalledApplications()를 사용하여 모든 앱 목록을 순회하는 비효율적인 방법을 사용했지만, getApplicationInfo()를 사용하면 훨씬 효율적입니다.


import android.content.pm.PackageManager

/**
 * 패키지 이름으로부터 애플리케이션의 UID를 반환합니다.
 * @param context 컨텍스트
 * @param packageName UID를 조회할 패키지 이름
 * @return 해당 패키지의 UID. 앱이 설치되어 있지 않다면 null을 반환합니다.
 */
private fun getUidForPackage(context: Context, packageName: String): Int? {
    return try {
        val packageManager = context.packageManager
        val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
        applicationInfo.uid
    } catch (e: PackageManager.NameNotFoundException) {
        // 해당 패키지 이름의 앱이 설치되어 있지 않은 경우
        e.printStackTrace()
        null
    }
}

이 함수는 `try-catch` 블록을 사용하여 앱이 설치되지 않았을 때 발생할 수 있는 NameNotFoundException을 안전하게 처리합니다.

NetworkStatsManager로 데이터 쿼리하기

UID를 얻었다면, 이제 NetworkStatsManagerqueryDetailsForUid() 메소드를 호출하여 실제 데이터를 가져올 수 있습니다. 이 메소드는 여러 파라미터를 필요로 합니다.

  • networkType: 조회할 네트워크 유형입니다. ConnectivityManager.TYPE_MOBILE 또는 ConnectivityManager.TYPE_WIFI와 같은 상수를 사용합니다.
  • subscriberId: 모바일 데이터의 경우, 통신사 가입자 식별자(IMSI)입니다. 일반적으로 null을 전달하면 현재 활성화된 네트워크를 기준으로 조회합니다. 듀얼 SIM 기기에서 특정 SIM을 구분하고 싶을 때 사용합니다.
  • startTime: 조회 시작 시간 (밀리초 단위, Unix 타임스탬프).
  • endTime: 조회 종료 시간 (밀리초 단위, Unix 타임스탬프).
  • uid: 조회할 애플리케이션의 UID.

이 메소드는 NetworkStats 객체를 반환하며, 이 객체는 조회된 데이터 집합을 담고 있습니다.

결과 처리 및 사용량 계산

queryDetailsForUid()가 반환한 NetworkStats 객체는 직접 데이터를 담고 있는 것이 아니라, 데이터 버킷(Bucket)에 대한 '커서(cursor)'와 같은 역할을 합니다. 따라서 `while` 루프를 사용하여 각 버킷을 순회하며 데이터를 읽어와야 합니다.

NetworkStats.Bucket 객체는 특정 시간 동안의 네트워크 사용량 정보를 담고 있으며, 주요 속성은 다음과 같습니다.

  • uid: 해당 버킷의 소유자 UID.
  • rxBytes: 수신한 데이터 양 (Bytes).
  • txBytes: 송신한 데이터 양 (Bytes).
  • rxPackets: 수신한 패킷 수.
  • txPackets: 송신한 패킷 수.

이제 이 모든 것을 종합하여 특정 앱의 데이터 사용량을 측정하는 완전한 함수를 만들어 보겠습니다.


import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import androidx.annotation.RequiresApi

/**
 * 지정된 패키지의 특정 기간 동안의 네트워크 데이터 사용량을 바이트 단위로 반환합니다.
 *
 * @param context 컨텍스트
 * @param packageName 데이터 사용량을 조회할 앱의 패키지 이름
 * @param networkType 조회할 네트워크 타입 (예: ConnectivityManager.TYPE_MOBILE)
 * @param startTime 조회 시작 시간 (Unix timestamp, milliseconds)
 * @param endTime 조회 종료 시간 (Unix timestamp, milliseconds)
 * @return 총 데이터 사용량 (수신 + 송신) 바이트. 오류 발생 시 0L 반환.
 */
@RequiresApi(Build.VERSION_CODES.M)
fun getPackageDataUsage(
    context: Context,
    packageName: String,
    networkType: Int,
    startTime: Long,
    endTime: Long
): Long {
    // 1. 시스템 서비스 및 UID 가져오기
    val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
    val uid = getUidForPackage(context, packageName) ?: return 0L // UID 없으면 0 반환

    var totalBytes = 0L
    var networkStats: NetworkStats? = null

    try {
        // 2. 데이터 쿼리
        networkStats = networkStatsManager.queryDetailsForUid(
            networkType,
            null, // subscriberId, null이면 활성 네트워크 기준
            startTime,
            endTime,
            uid
        )

        val bucket = NetworkStats.Bucket()
        // 3. 결과 처리
        while (networkStats.hasNextBucket()) {
            networkStats.getNextBucket(bucket)
            // 수신(rx)과 송신(tx) 데이터를 모두 합산
            totalBytes += bucket.rxBytes
            totalBytes += bucket.txBytes
        }
    } catch (e: Exception) {
        // SecurityException 등 예외 처리
        e.printStackTrace()
    } finally {
        // 4. 리소스 정리
        networkStats?.close()
    }

    return totalBytes
}
중요: queryDetailsForUid는 UID로 이미 필터링된 결과를 반환하므로, 원문의 코드처럼 `while` 루프 내에서 `if (entry.uid == uid)`와 같이 다시 UID를 확인할 필요는 없습니다. 이는 불필요한 중복 검사입니다.

데이터 단위 변환 및 표시

위 함수는 사용량을 바이트(Byte) 단위로 반환합니다. 사용자에게 보여주기 위해서는 KB, MB, GB와 같이 더 읽기 쉬운 단위로 변환하는 것이 좋습니다. 이를 위한 헬퍼 함수를 작성할 수 있습니다.


import java.text.DecimalFormat

fun formatDataUsage(bytes: Long): String {
    if (bytes < 0) return "0 B"
    val kb = bytes / 1024.0
    val mb = bytes / (1024.0 * 1024.0)
    val gb = bytes / (1024.0 * 1024.0 * 1024.0)
    
    val decimalFormat = DecimalFormat("#.##")
    
    return when {
        gb >= 1 -> "${decimalFormat.format(gb)} GB"
        mb >= 1 -> "${decimalFormat.format(mb)} MB"
        kb >= 1 -> "${decimalFormat.format(kb)} KB"
        else -> "$bytes B"
    }
}

// 사용 예시
val totalUsageBytes = getPackageDataUsage(...)
val formattedUsage = formatDataUsage(totalUsageBytes)
// textView.text = formattedUsage

원문 코드에서 사용된 `total / 0x0100000` 방식은 16진수 리터럴을 사용한 MB 변환입니다. `0x100000`은 1,048,576 (1024 * 1024)과 같지만, 가독성을 위해 `1024 * 1024L`과 같이 명시적으로 계산하거나 상수를 사용하는 것이 더 나은 프로그래밍 습관입니다.

3단계: 고급 활용 및 주요 고려사항

기본적인 사용법을 익혔다면, 이제 NetworkStatsManager를 더 깊이 있게 활용하는 방법을 알아볼 차례입니다.

포그라운드 vs. 백그라운드 데이터 사용량 분리

사용자들은 앱이 화면에 보이지 않는 동안(백그라운드에서) 데이터를 얼마나 사용하는지에 대해 매우 민감합니다. NetworkStats.Bucket 객체는 앱의 상태(state) 정보를 포함하고 있어, 포그라운드와 백그라운드 사용량을 분리하여 측정할 수 있습니다.

bucket.state 속성은 다음 값 중 하나를 가질 수 있습니다.

  • NetworkStats.Bucket.STATE_ALL: 전체 사용량 (포그라운드 + 백그라운드)
  • NetworkStats.Bucket.STATE_DEFAULT: 앱이 백그라운드에 있을 때의 사용량
  • NetworkStats.Bucket.STATE_FOREGROUND: 앱이 포그라운드에 있을 때의 사용량

이를 활용하여 기존 함수를 수정하면 각 상태별 사용량을 따로 집계할 수 있습니다.


// ... getPackageDataUsage 함수 내부 ...
var foregroundBytes = 0L
var backgroundBytes = 0L

while (networkStats.hasNextBucket()) {
    networkStats.getNextBucket(bucket)
    val usage = bucket.rxBytes + bucket.txBytes
    when (bucket.state) {
        NetworkStats.Bucket.STATE_FOREGROUND -> foregroundBytes += usage
        NetworkStats.Bucket.STATE_DEFAULT -> backgroundBytes += usage
        // STATE_ALL은 개별 상태의 합이므로 따로 더하지 않음
    }
}
// 결과로 Pair(foregroundBytes, backgroundBytes) 또는 데이터 클래스를 반환

전체 기기 데이터 사용량 집계

특정 앱이 아닌, 기기 전체의 데이터 사용량을 알고 싶을 때는 querySummary() 메소드를 사용합니다. 이 메소드는 UID를 인자로 받지 않으며, 지정된 시간 동안의 전체 네트워크 유형별 사용량 요약을 반환합니다.


@RequiresApi(Build.VERSION_CODES.M)
fun getDeviceTotalUsage(context: Context, networkType: Int, startTime: Long, endTime: Long): Long {
    val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
    var totalBytes = 0L
    try {
        val bucket = networkStatsManager.querySummaryForDevice(networkType, null, startTime, endTime)
        totalBytes = bucket.rxBytes + bucket.txBytes
    } catch(e: Exception) {
        e.printStackTrace()
    }
    return totalBytes
}

성능 및 스레딩

NetworkStatsManager를 통한 데이터 쿼리는 시스템 리소스를 사용하며, 특히 조회 기간이 길거나 데이터가 많을 경우 시간이 걸릴 수 있습니다. 따라서 메인(UI) 스레드에서 직접 호출하는 것은 피해야 합니다. 이는 ANR(Application Not Responding)을 유발할 수 있습니다. 코루틴(Coroutines), RxJava, 또는 간단한 `Thread`를 사용하여 백그라운드 스레드에서 데이터 조회 작업을 수행하고, 결과만 UI 스레드로 전달하는 것이 바람직합니다.


// 코루틴을 사용한 예시
suspend fun getDataUsageAsync(
    // ... 파라미터들 ...
): Long = withContext(Dispatchers.IO) {
    // getPackageDataUsage 함수 호출
    getPackageDataUsage(...)
}

// ViewModel 또는 Activity에서 호출
lifecycleScope.launch {
    val usage = getDataUsageAsync(...)
    // UI 업데이트
}

결론

지금까지 안드로이드에서 NetworkStatsManager를 사용하여 앱별 데이터 사용량을 측정하는 정교한 방법을 단계별로 살펴보았습니다. 핵심은 (1) PACKAGE_USAGE_STATS 권한을 정확히 이해하고 사용자로부터 획득하는 것, (2) 패키지 이름으로부터 UID를 효율적으로 조회하는 것, 그리고 (3) queryDetailsForUid를 통해 얻은 NetworkStats를 올바르게 순회하고 처리하는 것입니다. 또한, 포그라운드/백그라운드 사용량 분리, 전체 기기 사용량 조회와 같은 고급 기능을 활용하고, 백그라운드 스레딩을 통해 앱의 반응성을 유지하는 것이 완성도 높은 데이터 관리 앱을 만드는 데 필수적입니다.

이 글에서 다룬 지식과 코드를 바탕으로 사용자가 자신의 데이터 사용 패턴을 명확하게 파악하고 제어할 수 있도록 돕는 유용한 기능을 구현할 수 있을 것입니다.


0 개의 댓글:

Post a Comment