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 클래스는 여러 로그 레벨을 제공합니다.
| 로그 레벨 | 메서드 | 용도 |
|---|---|---|
| VERBOSE | Log.v() | 매우 상세한 실행 흐름 |
| DEBUG | Log.d() | 개발 중 상태 확인 |
| INFO | Log.i() | 주요 이벤트 기록 |
| WARN | Log.w() | 예상 가능한 문제 및 경고 |
| ERROR | Log.e() | 오류 및 예외 |
| ASSERT | Log.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 / Timber | Crashlytics |
|---|---|---|
| 주요 목적 | 개발 중 실행 흐름 확인 | 운영 중 오류 분석 |
| 상세 디버깅 | 적합 | 제한적 |
| 비정상 종료 분석 | 로컬 확인 | 운영 데이터 분석 |
| 사용자 기기 데이터 | 직접 접근 제한 | 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 | 목록 갱신 이벤트 |
| 상세 화면 | 데이터 로딩 상태 |
| 좋아요 | 저장 성공 여부 |
| 이미지 | 로딩 실패 여부 |
| WebView | JavaScript 통신 오류 |
| 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 앱에서 로그는 문제 해결을 위한 가장 기본적인 도구입니다.
하지만 로그를 많이 출력한다고 디버깅이 쉬워지는 것은 아닙니다.
중요한 것은 필요한 정보를 일관된 형식으로 기록하고, 문제가 발생했을 때 원인을 빠르게 찾을 수 있도록 구성하는 것입니다.
이번 글의 핵심을 정리하면 다음과 같습니다.
로그 레벨과 태그를 일관되게 관리합니다.
Timber를 활용하면 로그 정책을 중앙에서 관리하기 쉽습니다.
Debug와 Release 빌드의 로그 정책을 구분합니다.
Coroutine 오류와 취소를 구분해 처리합니다.
OkHttp 로깅에서는 인증 정보와 사용자 데이터를 보호해야 합니다.
SQLite 로그에는 사용자 입력값을 불필요하게 출력하지 않습니다.
StrictMode로 메인 스레드의 잘못된 작업을 탐지할 수 있습니다.
WebView 콘솔 로그와 원격 디버깅은 개발 환경에서 활용합니다.
운영 오류 분석에는 Crashlytics 같은 도구를 활용할 수 있습니다.
로그에 개인정보를 남기지 않는 것이 가장 중요한 보안 원칙입니다.
기존 Kotlin + XML 기반 Android 앱이라면 우선 Log.d()와 Log.e()가 사용된 위치를 검색하는 것부터 시작하는 것이 좋습니다.
그다음 기능별 로그 태그를 정리하고, Timber와 StrictMode를 적용한 뒤, Release 빌드에서 민감정보가 출력되지 않는지 확인하면 됩니다.
좋은 로그 시스템은 많은 정보를 기록하는 시스템이 아니라, 필요한 정보를 안전하게 기록하고 문제 해결에 활용할 수 있는 시스템입니다.
참고 자료
Android Developers — Logcat
https://developer.android.com/studio/debug/logcatAndroid Developers — Log 클래스
https://developer.android.com/reference/android/util/LogAndroid Developers — StrictMode
https://developer.android.com/reference/android/os/StrictModeTimber GitHub
https://github.com/JakeWharton/timberOkHttp Logging Interceptor
https://github.com/square/okhttp/tree/master/okhttp-logging-interceptorAndroid Developers — Debug WebView
https://developer.android.com/develop/ui/views/layout/webapps/debuggingFirebase — Crashlytics
https://firebase.google.com/docs/crashlyticsAndroid Developers — App Performance
https://developer.android.com/topic/performance
태그: Android, Kotlin, Logcat, Timber, StrictMode, OkHttp, Retrofit2, Android 디버깅, Android 로그 관리, Crashlytics, WebView, SQLite, 개인정보 보호, API 보안, Coroutine, Android Studio, Android 앱 개발