Android 앱 로그 관리와 디버깅 완벽 가이드 — Timber, Logcat, StrictMode, OkHttp 로깅과 개인정보 보호

Android 앱을 개발하다 보면 가장 자주 사용하는 디버깅 방법 중 하나가 로그 출력입니다.

Log.d("MainActivity", "onCreate 실행")

간단한 로그 한 줄만으로도 특정 함수가 실행되었는지 확인할 수 있습니다.

하지만 앱의 기능이 많아지면 상황이 달라집니다.

예를 들어 조류 도감 앱에서 다음과 같은 문제가 발생했다고 가정하겠습니다.

  • SQLite 검색 결과가 간헐적으로 표시되지 않는다.

  • RecyclerView에서 좋아요 버튼을 눌러도 상태가 변경되지 않는다.

  • Retrofit API 호출이 실패한다.

  • WebView에서 JavaScript 메시지가 전달되지 않는다.

  • AdMob 광고가 로드되지 않는다.

  • 특정 Android 버전에서 앱이 느려진다.

이러한 문제를 해결하려면 단순히 로그를 많이 출력하는 것이 아니라, 필요한 정보를 일관된 형식으로 기록하고 분석할 수 있어야 합니다.

반대로 로그에 사용자 개인정보나 인증 토큰을 그대로 출력하면 보안 문제가 발생할 수 있습니다.

이번 글에서는 Android 앱의 로그 관리 구조를 개선하고, 디버깅 도구를 활용하는 방법을 실전 코드와 함께 살펴보겠습니다.

개발 환경: Kotlin, XML ViewBinding, MVVM, Coroutine, Retrofit2, OkHttp, SQLiteOpenHelper, WebView, AdMob, minSdk 24 이상. 라이브러리 버전과 Android Studio 메뉴는 사용하는 개발 환경에 맞게 확인해야 합니다.


1. Android 로그 관리가 중요한 이유

개발 초기에는 다음과 같이 로그를 작성하는 경우가 많습니다.

Log.d("TEST", "여기 실행됨")
Log.d("TEST", "데이터 = $data")
Log.d("TEST", "에러 발생")

이 방식은 간단하지만 프로젝트가 커지면 문제가 발생합니다.

첫째, 로그를 출력한 위치를 파악하기 어렵습니다.

둘째, 같은 태그를 여러 곳에서 사용하면 로그를 구분하기 어렵습니다.

셋째, 정상적인 로그와 오류 로그가 섞입니다.

넷째, 사용자 정보나 서버 응답이 그대로 노출될 수 있습니다.

다섯째, 출시 버전에서도 불필요한 로그가 남을 수 있습니다.

따라서 앱의 규모가 커질수록 로그 작성 규칙을 정하는 것이 좋습니다.


Android Log 클래스 이해하기

2. 로그 레벨 종류

Android의 Log 클래스는 여러 로그 레벨을 제공합니다.

로그 레벨메서드용도
VERBOSELog.v()매우 상세한 실행 흐름
DEBUGLog.d()개발 중 상태 확인
INFOLog.i()주요 이벤트 기록
WARNLog.w()예상 가능한 문제 및 경고
ERRORLog.e()오류 및 예외
ASSERTLog.wtf()발생해서는 안 되는 심각한 상황

예를 들어 다음과 같이 사용할 수 있습니다.

Log.d("BirdRepository", "검색 시작")
Log.i("BirdRepository", "데이터 갱신 완료")
Log.w("BirdRepository", "캐시 데이터 사용")
Log.e("BirdRepository", "검색 실패", exception)

중요한 점은 로그 레벨을 상황에 맞게 사용하는 것입니다.

정상적인 동작을 모두 ERROR로 출력하면 실제 오류를 구분하기 어려워집니다.


3. Logcat에서 로그 확인하기

Android Studio의 Logcat에서는 실행 중인 앱의 로그를 확인할 수 있습니다.

일반적으로 다음 항목을 기준으로 필터링할 수 있습니다.

  • 앱 프로세스

  • 로그 레벨

  • 태그

  • 메시지

  • 특정 문자열

예를 들어 다음과 같은 로그를 출력했다고 가정하겠습니다.

Log.d(
    "BirdSearch",
    "검색 결과: ${birds.size}건"
)

Logcat에서 BirdSearch를 검색하면 관련 로그를 쉽게 찾을 수 있습니다.

최신 Android Studio의 Logcat은 구조화된 검색 조건을 지원하지만, 구체적인 필터 문법과 UI는 버전에 따라 달라질 수 있습니다.


로그 태그 설계

4. 기능별 태그 사용하기

앱 전체에서 TEST, DEBUG, LOG 같은 태그만 사용하는 것은 추천하지 않습니다.

대신 기능별 태그를 사용할 수 있습니다.

BirdSearch
BirdDatabase
BirdDetail
BirdImage
BirdWebView
BirdAdMob
BirdSync

예를 들어 검색 기능에서는 다음과 같이 작성합니다.

private const val TAG = "BirdSearch"

fun search(keyword: String) {

    Log.d(TAG, "검색 시작")

    // 검색 수행

    Log.d(TAG, "검색 종료")
}

이렇게 하면 로그를 기능별로 구분하기 쉽습니다.


5. 로그 메시지 형식 통일하기

로그 메시지의 형식을 일정하게 유지하면 분석이 쉬워집니다.

예를 들어 다음과 같은 형식을 사용할 수 있습니다.

[기능] 작업 상태 / 주요 정보

실제 로그 예제입니다.

Log.d(
    "BirdSearch",
    "[Search] START / keywordLength=${keyword.length}"
)

Log.d(
    "BirdSearch",
    "[Search] SUCCESS / count=${birds.size}"
)

여기서는 검색어 전체 대신 검색어 길이를 기록했습니다.

검색어 자체가 개인정보나 민감한 내용을 포함할 수 있기 때문입니다.


Timber 도입하기

6. Timber란?

Timber는 Android 로그 출력을 편리하게 관리할 수 있도록 도와주는 로깅 라이브러리입니다.

기본 Android Log 클래스를 직접 사용하는 대신 일관된 로깅 방식을 적용할 수 있습니다.

예를 들어 다음과 같이 사용할 수 있습니다.

Timber.d("검색 시작")
Timber.i("데이터 갱신 완료")
Timber.w("캐시 데이터 사용")
Timber.e(exception, "검색 실패")

Timber의 장점은 로그 출력 정책을 중앙에서 관리할 수 있다는 점입니다.

특히 Debug와 Release 빌드에서 서로 다른 로그 정책을 적용할 때 유용합니다.


7. Gradle 의존성 추가

앱 모듈의 build.gradle.kts에 Timber 의존성을 추가합니다.

dependencies {

    implementation(
        "com.jakewharton.timber:timber:<version>"
    )
}

<version>은 공식 저장소에서 확인한 호환 버전으로 교체해야 합니다.

실제 Gradle 파일에 자리표시자를 그대로 입력하면 빌드할 수 없습니다.


8. Application에서 Timber 초기화

Timber는 일반적으로 Application 클래스에서 초기화합니다.

class BirdApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {
            Timber.plant(
                Timber.DebugTree()
            )
        }
    }
}

DebugTree는 로그 태그를 자동으로 생성하는 등의 편의 기능을 제공합니다.

BuildConfig.DEBUG 조건을 사용하면 Debug 빌드에서만 DebugTree를 등록할 수 있습니다.

단, 라이브러리 모듈이나 일부 Gradle 구성에서는 BuildConfig 생성 설정을 별도로 확인해야 합니다.


9. AndroidManifest에 Application 등록

Application 클래스를 만들었다면 Manifest에 등록합니다.

<application
    android:name=".BirdApplication"
    android:allowBackup="true"
    android:theme="@style/Theme.BirdApp">

</application>

기존 Application 클래스가 있다면 새 클래스를 추가하는 대신 기존 클래스에서 Timber를 초기화하면 됩니다.

Hilt를 사용한다면 @HiltAndroidApp이 적용된 Application 클래스에서 함께 초기화할 수 있습니다.


Debug와 Release 로그 분리

10. 출시 버전에서 Debug 로그 제거하기

개발 중에는 상세한 로그가 필요하지만 실제 사용자에게 배포하는 앱에서는 불필요한 로그를 최소화해야 합니다.

Timber에서는 Debug 빌드에만 DebugTree를 등록하는 방식을 사용할 수 있습니다.

if (BuildConfig.DEBUG) {

    Timber.plant(
        Timber.DebugTree()
    )
}

Release 빌드에서 다른 Tree를 등록하지 않았다면 Timber를 통한 일반적인 로그 출력은 기록되지 않습니다.

하지만 이것만으로 모든 로그가 사라지는 것은 아닙니다.

앱에서 직접 호출하는 Log.d(), 외부 라이브러리의 로그, 시스템 로그 등은 별도로 고려해야 합니다.


11. 운영 환경에서는 오류 정보를 어떻게 확인할까?

Release 빌드에서 상세 로그를 제거하면 운영 중 발생한 문제를 파악하기 어려울 수 있습니다.

이 경우 Firebase Crashlytics 같은 오류 추적 도구를 활용할 수 있습니다.

예를 들어 예상하지 못한 예외를 기록할 수 있습니다.

FirebaseCrashlytics
    .getInstance()
    .recordException(exception)

다만 Crashlytics에 전달하는 예외 메시지와 사용자 정의 로그에도 개인정보가 포함되지 않도록 주의해야 합니다.

또한 오류 추적 도구는 일반적인 Debug 로그를 무제한 보관하는 용도로 사용하지 않는 것이 좋습니다.


Coroutine 로그 관리

12. Coroutine 오류가 발생했을 때

Coroutine을 사용하는 앱에서는 비동기 작업의 오류를 적절하게 기록해야 합니다.

예를 들어 ViewModel에서 서버 데이터를 가져오는 코드입니다.

fun loadBirds() {

    viewModelScope.launch {

        try {

            val birds =
                repository.getBirds()

            Timber.d(
                "Bird loading success: %d",
                birds.size
            )

        } catch (e: CancellationException) {

            throw e

        } catch (e: Exception) {

            Timber.e(
                e,
                "Bird loading failed"
            )
        }
    }
}

여기서 중요한 부분은 CancellationException입니다.

Coroutine 취소는 일반적인 오류와 다릅니다.

따라서 취소 예외를 무조건 실패로 처리하지 않고 다시 전달하는 것이 좋습니다.


13. 예외 메시지만 출력하지 않기

다음과 같은 코드는 오류 원인을 파악하기 어려울 수 있습니다.

Timber.e(exception.message)

예외 메시지만 기록하면 스택 트레이스가 누락될 수 있습니다.

대신 다음과 같이 예외 객체를 전달하는 것이 좋습니다.

Timber.e(
    exception,
    "Failed to load bird data"
)

다만 예외 객체의 메시지 자체에 URL 파라미터, 서버 응답, 사용자 정보 등이 포함될 수 있습니다.

따라서 예외를 그대로 기록하는 정책도 개인정보 관점에서 검토해야 합니다.


Retrofit2와 OkHttp 로깅

14. 네트워크 통신 로그가 필요한 이유

Retrofit2와 OkHttp를 사용하는 앱에서는 다음과 같은 문제가 발생할 수 있습니다.

  • 서버 응답 지연

  • HTTP 401 인증 오류

  • HTTP 403 접근 제한

  • HTTP 404 잘못된 주소

  • HTTP 500 서버 오류

  • JSON 파싱 실패

  • 네트워크 연결 실패

이러한 문제를 분석하려면 요청과 응답의 상태를 확인하는 것이 도움이 됩니다.

하지만 요청 본문이나 응답 본문을 무조건 출력하는 것은 위험할 수 있습니다.


15. HttpLoggingInterceptor 사용하기

OkHttp에서는 HttpLoggingInterceptor를 사용할 수 있습니다.

Gradle 의존성 예제입니다.

dependencies {

    implementation(
        "com.squareup.okhttp3:logging-interceptor:<version>"
    )
}

다음과 같이 인터셉터를 생성할 수 있습니다.

val loggingInterceptor =
    HttpLoggingInterceptor { message ->

        Timber.tag("OkHttp")
            .d("%s", message)
    }.apply {

        level =
            HttpLoggingInterceptor.Level.BASIC
    }

BASIC 레벨은 요청 및 응답의 기본 정보를 기록합니다.

다만 URL 자체에도 검색어, 토큰, 개인식별정보 등이 포함될 수 있으므로 BASIC 로그 역시 무조건 안전하다고 볼 수는 없습니다.


16. OkHttp 로그 레벨 비교

레벨기록 범위
NONE로깅 비활성화
BASIC요청 및 응답의 기본 정보
HEADERS기본 정보와 헤더
BODY헤더와 본문을 포함한 상세 정보

개발 중이라도 BODY 레벨은 신중하게 사용해야 합니다.

특히 다음 정보가 포함된 요청은 주의해야 합니다.

  • Authorization 헤더

  • Cookie

  • 로그인 비밀번호

  • Access Token

  • Refresh Token

  • 사용자 이메일

  • 전화번호

  • 주소

  • 위치 정보


17. 민감한 헤더 가리기

HttpLoggingInterceptor에서는 특정 헤더를 가리는 기능을 제공합니다.

val interceptor =
    HttpLoggingInterceptor().apply {

        level =
            HttpLoggingInterceptor.Level.HEADERS

        redactHeader("Authorization")
        redactHeader("Cookie")
        redactHeader("Set-Cookie")
    }

이렇게 하면 지정한 헤더의 값이 로그에 그대로 출력되지 않도록 처리할 수 있습니다.

하지만 헤더를 가리는 것만으로 모든 민감정보가 보호되는 것은 아닙니다.

URL, 요청 본문, 응답 본문, 사용자 정의 로그에도 민감정보가 포함될 수 있기 때문입니다.


18. Debug 빌드에서만 네트워크 로그 활성화

val okHttpBuilder =
    OkHttpClient.Builder()

if (BuildConfig.DEBUG) {

    val loggingInterceptor =
        HttpLoggingInterceptor().apply {

            level =
                HttpLoggingInterceptor.Level.BASIC

            redactHeader("Authorization")
            redactHeader("Cookie")
        }

    okHttpBuilder.addInterceptor(
        loggingInterceptor
    )
}

val okHttpClient =
    okHttpBuilder.build()

이렇게 하면 Release 빌드에서 네트워크 디버깅 로그를 비활성화할 수 있습니다.

실제 운영 환경에서는 필요한 상태 코드나 처리 시간만 별도 정책에 따라 기록하는 방법을 고려할 수 있습니다.


SQLite 로그 관리

19. 데이터베이스 오류 기록

SQLite를 직접 사용하는 앱에서는 쿼리 오류를 적절하게 기록하는 것이 중요합니다.

fun findBirdById(
    birdId: String
): Bird? {

    return try {

        // SQLite 조회 처리
        queryBird(birdId)

    } catch (e: SQLiteException) {

        Timber.e(
            e,
            "Bird query failed"
        )

        null
    }
}

이 예제는 오류 기록의 기본 형태를 보여줍니다.

다만 실제 Repository에서는 오류를 단순히 null로 바꾸면 데이터가 없는 경우와 조회 실패를 구분할 수 없게 됩니다.

따라서 Result, sealed class 또는 명시적인 오류 모델을 사용하는 것이 더 적합할 수 있습니다.


20. SQL 쿼리 전체를 출력해도 될까?

다음과 같은 코드는 주의해야 합니다.

Timber.d("SQL: $query")

SQL 문자열에 사용자 입력값이나 개인정보가 포함되어 있다면 로그에 노출될 수 있습니다.

특히 메모, 검색어, 사용자 식별자 등을 직접 문자열로 결합하는 방식은 피해야 합니다.

SQL 실행에는 바인딩 인자를 사용하는 것이 좋습니다.

database.rawQuery(
    """
    SELECT *
    FROM korean_birds
    WHERE KoreanName LIKE ?
    """.trimIndent(),
    arrayOf("%$keyword%")
)

성능을 분석할 때는 전체 SQL 인자보다 다음 정보를 기록하는 편이 안전할 수 있습니다.

Timber.d(
    "Bird search completed / count=%d",
    resultCount
)

StrictMode 활용

21. StrictMode란?

StrictMode는 개발 중 앱의 잘못된 실행 패턴을 발견하는 데 도움을 주는 Android 디버깅 도구입니다.

대표적으로 다음 문제를 탐지하는 데 사용할 수 있습니다.

  • 메인 스레드의 디스크 접근

  • 메인 스레드의 네트워크 접근

  • 일부 리소스 누수

  • 잘못된 스레드 사용 패턴

StrictMode는 앱 성능과 안정성을 개선하는 데 유용하지만, 모든 ANR이나 메모리 누수를 자동으로 찾아주는 도구는 아닙니다.


22. StrictMode 설정

Application 클래스에서 Debug 빌드에만 StrictMode를 활성화할 수 있습니다.

class BirdApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {

            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )

            StrictMode.setVmPolicy(
                StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

이 설정은 메인 스레드의 디스크 및 네트워크 접근과 일부 SQLite 리소스 누수 등을 감지하는 데 도움이 됩니다.

단, 정상적인 초기화 과정에서도 디스크 접근 경고가 발생할 수 있습니다.

경고가 발생했다는 이유만으로 모든 코드를 무조건 백그라운드로 이동하기보다 실제 작업의 비용과 실행 맥락을 분석해야 합니다.


23. SQLite 메인 스레드 접근 감지

다음과 같은 코드는 주의해야 합니다.

override fun onCreate(
    savedInstanceState: Bundle?
) {
    super.onCreate(savedInstanceState)

    val birds =
        database.getAllBirds()
}

데이터베이스 조회가 메인 스레드에서 실행되면 화면 표시가 지연될 수 있습니다.

StrictMode를 활성화하면 이러한 디스크 접근을 확인하는 데 도움이 됩니다.

개선 예제입니다.

viewModelScope.launch {

    val birds =
        withContext(Dispatchers.IO) {
            repository.getAllBirds()
        }

    _birds.value = birds
}

이 코드는 ViewModel 내부에서 사용하는 형태입니다.

단, repository.getAllBirds()가 동기 함수라고 가정합니다.


WebView 디버깅

24. WebView에서 JavaScript 오류 확인하기

WebView를 사용하는 앱에서는 웹 페이지 내부의 JavaScript 오류가 Android 코드에 직접 표시되지 않을 수 있습니다.

이 경우 WebChromeClient의 onConsoleMessage()를 활용할 수 있습니다.

webView.webChromeClient =
    object : WebChromeClient() {

        override fun onConsoleMessage(
            consoleMessage: ConsoleMessage
        ): Boolean {

            if (BuildConfig.DEBUG) {

                Timber.tag("WebView")
                    .d(
                        "JS console: %s",
                        consoleMessage.message()
                    )
            }

            return true
        }
    }

이 코드는 JavaScript 콘솔 메시지를 Android 로그로 전달하는 예제입니다.

다만 콘솔 메시지에 로그인 정보나 페이지 데이터가 포함될 수 있으므로 Debug 빌드에서도 주의해야 합니다.


25. WebView 원격 디버깅

개발 중에는 WebView 원격 디버깅 기능을 사용할 수 있습니다.

if (BuildConfig.DEBUG) {

    WebView.setWebContentsDebuggingEnabled(
        true
    )
}

이 기능을 사용하면 지원되는 개발 환경에서 WebView 콘텐츠를 검사할 수 있습니다.

단, 원격 디버깅은 웹 콘텐츠에 대한 상세한 접근을 허용할 수 있으므로 출시 버전에서는 비활성화하는 것이 좋습니다.

특히 로그인 세션이나 사용자 데이터를 처리하는 WebView에서는 주의가 필요합니다.


AdMob 로그 분석

26. 광고 로딩 실패 기록

AdMob 광고는 네트워크 상태, 광고 인벤토리, SDK 설정 등에 따라 로딩에 실패할 수 있습니다.

예를 들어 다음과 같이 실패 정보를 기록할 수 있습니다.

override fun onAdFailedToLoad(
    error: LoadAdError
) {

    Timber.tag("AdMob")
        .w(
            "Ad load failed / code=%d",
            error.code
        )
}

광고 로딩 실패가 발생했다고 해서 반드시 앱 코드에 문제가 있는 것은 아닙니다.

오류 코드, 응답 정보, 테스트 광고 사용 여부, AdMob 설정 등을 함께 확인해야 합니다.

운영 환경에서는 광고 응답의 상세 정보를 무분별하게 수집하지 않는 것이 좋습니다.


로그와 개인정보 보호

27. 로그에 남기면 안 되는 정보

개발 중에는 편의를 위해 사용자 정보를 그대로 출력하는 경우가 있습니다.

하지만 다음 정보는 로그에 직접 기록하지 않는 것이 좋습니다.

정보주의 이유
비밀번호계정 탈취 위험
Access Token인증 정보 악용 가능
Refresh Token장기 세션 탈취 위험
API Key서비스 오남용 위험
이메일개인정보 노출
전화번호개인정보 노출
주소위치 및 개인정보 노출
GPS 좌표위치정보 노출
사용자 메모개인적인 내용 포함 가능
서버 응답 전체민감정보 포함 가능

특히 API 응답을 통째로 출력하는 습관은 피하는 것이 좋습니다.


28. 안전한 로그 작성 예제

다음 코드는 권장하지 않습니다.

Timber.d(
    "Login token: $accessToken"
)

대신 다음과 같이 기록할 수 있습니다.

Timber.d(
    "Login completed / authenticated=%s",
    true
)

인증 성공 여부나 처리 상태만 기록해도 문제 분석에 충분한 경우가 많습니다.

또한 사용자 이메일이나 전화번호를 일부만 가린다고 해서 항상 안전한 것은 아닙니다.

필요하지 않은 정보라면 애초에 기록하지 않는 것이 가장 좋습니다.


운영 로그 설계

29. 운영 환경에서는 무엇을 기록할까?

Release 빌드에서는 상세한 개발용 로그보다 오류 분석에 필요한 최소한의 정보를 기록하는 것이 좋습니다.

예를 들어 다음과 같습니다.

  • 앱 버전

  • Android API 레벨

  • 기능 이름

  • 오류 유형

  • HTTP 상태 코드

  • 처리 성공 여부

  • 예외 발생 위치

다만 기기 정보와 오류 정보도 다른 데이터와 결합되면 사용자를 식별할 가능성이 있으므로 수집 목적과 보관 정책을 검토해야 합니다.


30. 로그와 Crashlytics의 역할 구분

구분Logcat / TimberCrashlytics
주요 목적개발 중 실행 흐름 확인운영 중 오류 분석
상세 디버깅적합제한적
비정상 종료 분석로컬 확인운영 데이터 분석
사용자 기기 데이터직접 접근 제한SDK 정책에 따라 수집
개인정보 관리개발자가 통제수집 설정 및 정책 검토 필요

두 도구는 서로 대체 관계가 아닙니다.

개발 중에는 Logcat과 Timber를 활용하고, 운영 중에는 Crashlytics 같은 오류 추적 도구를 필요한 범위에서 사용하는 것이 좋습니다.


공통 로깅 구조 만들기

31. AppLogger 인터페이스

프로젝트 규모가 커지면 로깅 라이브러리를 직접 호출하지 않고 공통 인터페이스를 만드는 방법도 고려할 수 있습니다.

interface AppLogger {

    fun debug(message: String)

    fun info(message: String)

    fun warn(message: String)

    fun error(
        message: String,
        throwable: Throwable? = null
    )
}

이 인터페이스를 사용하면 나중에 로깅 구현체를 변경하기 쉬워집니다.


32. Timber 기반 구현

class TimberAppLogger : AppLogger {

    override fun debug(message: String) {
        Timber.d("%s", message)
    }

    override fun info(message: String) {
        Timber.i("%s", message)
    }

    override fun warn(message: String) {
        Timber.w("%s", message)
    }

    override fun error(
        message: String,
        throwable: Throwable?
    ) {
        Timber.e(
            throwable,
            "%s",
            message
        )
    }
}

이렇게 하면 Repository나 ViewModel에서 Timber에 직접 의존하지 않을 수 있습니다.

다만 작은 프로젝트에서는 인터페이스를 추가하는 것이 오히려 불필요한 복잡도를 만들 수도 있습니다.

앱 규모에 맞게 선택하는 것이 좋습니다.


성능 측정과 로그

33. 로그 출력 자체도 비용이 있다

로그 출력은 완전히 비용이 없는 작업이 아닙니다.

특히 다음과 같은 상황에서는 성능에 영향을 줄 수 있습니다.

  • 매우 짧은 반복문에서 로그 출력

  • RecyclerView 바인딩마다 상세 로그 출력

  • 대량 JSON 문자열 생성

  • Bitmap 정보를 반복 변환

  • 고빈도 위치 업데이트마다 로그 기록

예를 들어 다음과 같은 코드는 주의해야 합니다.

for (bird in birds) {

    Timber.d(
        "Bird: ${bird.toString()}"
    )
}

데이터가 수천 건이라면 많은 문자열 생성과 로그 출력이 발생할 수 있습니다.


34. 성능 측정에서는 로그 최소화

앱 시작 시간이나 RecyclerView 스크롤 성능을 측정할 때는 상세 로그가 측정 결과에 영향을 줄 수 있습니다.

따라서 Macrobenchmark나 Baseline Profile 관련 성능 테스트에서는 불필요한 디버깅 로그를 비활성화하는 것이 좋습니다.

성능 측정용 빌드와 일반 Debug 빌드의 설정을 구분하는 방법도 고려할 수 있습니다.


실제 프로젝트 적용 사례

35. 조류 도감 앱 로그 구성

조류 도감 앱에서는 다음과 같이 기능별 로그를 구성할 수 있습니다.

기능로그 항목
앱 시작초기화 성공 및 소요 시간
SQLite조회 성공, 결과 개수, 오류 유형
검색검색 시작 및 결과 개수
RecyclerView목록 갱신 이벤트
상세 화면데이터 로딩 상태
좋아요저장 성공 여부
이미지로딩 실패 여부
WebViewJavaScript 통신 오류
AdMob광고 로딩 실패 코드
동기화성공, 실패, 재시도 상태

이때 사용자 검색어, 메모 내용, API 응답 전체를 기록하지 않는 것이 중요합니다.


36. 기능별 로그 예제

class BirdRepository(
    private val database: BirdDatabase
) {

    suspend fun searchBirds(
        keyword: String
    ): List<Bird> {

        Timber.tag("BirdSearch")
            .d(
                "Search started / length=%d",
                keyword.length
            )

        val result =
            withContext(Dispatchers.IO) {
                database.searchBirds(keyword)
            }

        Timber.tag("BirdSearch")
            .d(
                "Search completed / count=%d",
                result.size
            )

        return result
    }
}

이 예제에서는 검색어의 실제 내용을 기록하지 않고 검색어 길이와 결과 개수만 기록합니다.

또한 SQLite 작업을 IO Dispatcher에서 실행하도록 구성했습니다.


디버깅 체크리스트

37. 개발 중 확인할 사항

  • 로그 태그를 기능별로 구분

  • 로그 레벨을 용도에 맞게 사용

  • Debug와 Release 로그 정책 분리

  • Timber 초기화 위치 확인

  • 불필요한 로그 출력 제거

  • Coroutine 취소 예외 구분

  • 예외 스택 트레이스 확인

  • OkHttp 로깅 레벨 검토

  • Authorization 헤더 마스킹

  • Cookie 및 인증 정보 보호

  • URL 파라미터에 민감정보 포함 여부 확인

  • SQLite 사용자 입력 로그 최소화

  • StrictMode 활성화

  • 메인 스레드 디스크 접근 점검

  • WebView 콘솔 로그 점검

  • WebView 원격 디버깅 Release 비활성화

  • AdMob 오류 코드 확인

  • 사용자 개인정보 로그 출력 방지

  • Crashlytics 수집 데이터 검토

  • 성능 측정 시 상세 로그 최소화

  • 출시 전 Release 빌드 로그 점검


38. 마무리

Android 앱에서 로그는 문제 해결을 위한 가장 기본적인 도구입니다.

하지만 로그를 많이 출력한다고 디버깅이 쉬워지는 것은 아닙니다.

중요한 것은 필요한 정보를 일관된 형식으로 기록하고, 문제가 발생했을 때 원인을 빠르게 찾을 수 있도록 구성하는 것입니다.

이번 글의 핵심을 정리하면 다음과 같습니다.

  1. 로그 레벨과 태그를 일관되게 관리합니다.

  2. Timber를 활용하면 로그 정책을 중앙에서 관리하기 쉽습니다.

  3. Debug와 Release 빌드의 로그 정책을 구분합니다.

  4. Coroutine 오류와 취소를 구분해 처리합니다.

  5. OkHttp 로깅에서는 인증 정보와 사용자 데이터를 보호해야 합니다.

  6. SQLite 로그에는 사용자 입력값을 불필요하게 출력하지 않습니다.

  7. StrictMode로 메인 스레드의 잘못된 작업을 탐지할 수 있습니다.

  8. WebView 콘솔 로그와 원격 디버깅은 개발 환경에서 활용합니다.

  9. 운영 오류 분석에는 Crashlytics 같은 도구를 활용할 수 있습니다.

  10. 로그에 개인정보를 남기지 않는 것이 가장 중요한 보안 원칙입니다.

기존 Kotlin + XML 기반 Android 앱이라면 우선 Log.d()와 Log.e()가 사용된 위치를 검색하는 것부터 시작하는 것이 좋습니다.

그다음 기능별 로그 태그를 정리하고, Timber와 StrictMode를 적용한 뒤, Release 빌드에서 민감정보가 출력되지 않는지 확인하면 됩니다.

좋은 로그 시스템은 많은 정보를 기록하는 시스템이 아니라, 필요한 정보를 안전하게 기록하고 문제 해결에 활용할 수 있는 시스템입니다.


참고 자료

태그: Android, Kotlin, Logcat, Timber, StrictMode, OkHttp, Retrofit2, Android 디버깅, Android 로그 관리, Crashlytics, WebView, SQLite, 개인정보 보호, API 보안, Coroutine, Android Studio, Android 앱 개발