“무슨 작업하기도 바쁜데, 로깅 전략 문서까지 만들래…”
조이는 모니터에 턱을 괴고 투덜거렸다.
— 캠퍼스는 데모데이 전날답게 소음이 리듬처럼 섞였다. 클릭, 타닥, 한숨, 그리고 누군가의 “어-!” 라는 짤막한 비명.
비비는 빈 아메리카노 컵의 빨대를 입에 물고 들어오며 말했다.
“안 만들면 나중에 앱 터졌을 때 왜 터졌는지 아무도 몰라. Crashlytics도 있고 Analytics도 있는데, 예쁘게 써야지.
이건 우리 물깜 서비스에서의 로깅 전략이야.”
타마가 옆자리에서 안스를 켜고 중얼거렸다.
“그럼 레벨부터 박자. ERROR, WARN, INFO, DEBUG. 그~냥 싹싹김치.”
“그냥 println 쓰면 안 돼?” 조이가 뒤로 젖힌 의자에서 슬쩍 도발하자,
비비는 눈을 흘기며 싸늘하게 말한다.
“println은… 레아가 캠퍼스에 왔다가 친구한테만 ‘나 왔어~’ 하는 거랑 같아.
출석부에도 안 찍히고 기록도 안 남아. 나중에 코치가 ‘레아 오늘 왔었어?’ 물어보면 아무도 증명 못 해.”
비비: 로깅 얘기 전에, 해야 할 일들이 있어!
- Firebase Console 프로젝트 생성 → Android 앱 등록(패키지명 정확히)
- google-services.json 다운로드 → android/app/ 에 저장
- Gradle 세팅(필요한 것만)
// build.gradle.kts (Project)
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
id("com.google.firebase.crashlytics") version "3.0.5" apply false
}
// build.gradle.kts (Module: app)
plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.google.services) // ✅ google-services
alias(libs.plugins.firebase.crashlytics) // ✅ crashlytics
}
dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.timber)
}
// build.gradle.kts (Module: app) — buildTypes 예시
android {
buildTypes {
debug {
isMinifyEnabled = false
}
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
// app/src/main/java/com/mulkkam/MulKkamApp.kt
class MulKkamApp : Application() {
override fun onCreate() {
super.onCreate()
initLogger()
}
private fun initLogger() {
if (BuildConfig.DEBUG) {
Timber.plant(LoggingInjection.debugTimberTree)
} else {
Timber.plant(LoggingInjection.releaseTimberTree)
}
}
}
비비가 꺄르르 손뼉을 쳤다. “좋아. 이제 찍으면 남는다.”
조이가 오랑이처럼 피식 웃었다. “이제 변명 대신 증거로 말할 수 있네. 다 죽었다.”
타마: “형식부터 맞추자. 시간은 UTC, 사람 읽을 메시지, 이벤트, 레벨, 유저ID.”
// domain/model/LogLevel.kt
enum class LogLevel { ERROR, WARN, INFO, DEBUG }
// domain/model/LogEvent.kt
enum class LogEvent { NETWORK, USER_AUTH, USER_ACTION, ERROR }
// domain/model/LogEntry.kt
data class LogEntry(
val level: LogLevel,
val event: LogEvent,
val message: String,
val userId: String? = null,
val timestamp: String = DateTimeFormatter.ISO_INSTANT.format(Instant.now()),
)
조이: “어느 계층에서든 추상화된 인터페이스만 쓰게. 추후 유지보수를 하기 편하게!”
// domain/logger/Logger.kt
interface Logger {
fun log(entry: LogEntry)
fun error(event: LogEvent, message: String = "", userId: String? = null) =
log(LogEntry(LogLevel.ERROR, event, message, userId))
fun warn(event: LogEvent, message: String = "", userId: String? = null) =
log(LogEntry(LogLevel.WARN, event, message, userId))
fun info(event: LogEvent, message: String = "", userId: String? = null) =
log(LogEntry(LogLevel.INFO, event, message, userId))
fun debug(event: LogEvent, message: String = "", userId: String? = null) =
log(LogEntry(LogLevel.DEBUG, event, message, userId))
}
비비: “Tree도 분리. Debug는 콘솔, Release는 Crashlytics.”
// util/logger/DebugLoggingTree.kt
class DebugLoggingTree(
private val sanitizer: SensitiveInfoSanitizer,
) : Timber.DebugTree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
super.log(priority, tag, sanitizer.sanitize(message), t)
}
}
// util/logger/ReleaseLoggingTree.kt
class ReleaseLoggingTree(
private val sanitizer: SensitiveInfoSanitizer,
) : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority == Log.VERBOSE || priority == Log.DEBUG) return
val safe = sanitizer.sanitize(message)
FirebaseCrashlytics.getInstance().log(safe)
if (priority == Log.ERROR && t != null) {
FirebaseCrashlytics.getInstance().recordException(t)
}
}
}
특정 정보 마스킹 인터페이스:
// domain/logger/SensitiveInfoSanitizer.kt
fun interface SensitiveInfoSanitizer { fun sanitize(input: String): String }
다음 날 아침, 조이가 Firebase Analytics 콘솔을 보고 소리쳤다.
“야… 네트워크 이벤트가 5600개 찍혔어. 하루 만에. 우리 아직 정식 출시도 안했는데 ;”
비비: “아… DEBUG 로그까지 전부 Analytics로 보냈네. 요청·응답 하나하나가 사용자 이벤트로 올라갔어.”
if (!BuildConfig.DEBUG) {
Firebase.analytics.logEvent(entry.event.name, Bundle().apply {
putString("level", entry.level.name)
putString("message", safePayload)
entry.userId?.let { putString("userId", it) }
})
}
if (entry.level == LogLevel.ERROR) {
FirebaseCrashlytics.getInstance().recordException(Exception(safeLine))
}
네트워크 로그 점검 중, 타마: “야… 인증 토큰이 로그에 그대로 찍혀 있는데?”
조이: “진짜? 그거 털리면 끝장인데?”
비비가 노트북 화면을 돌려줬다. DEBUG 로그에 JWT 토큰이 길게 노출돼 있었다.
이건 단순 로깅 실수가 아니라, 보안 사고였다.
즉시 마스킹을 구현한다:
class SensitiveInfoSanitizerImpl(
private val mask: String = "***",
) : SensitiveInfoSanitizer {
private val JSON_TOKEN_REGEX = Regex("""(?i)"(token|access_token|refresh_token|deviceid)"\s*:\s*"([^"]+)"""")
private val KEY_VALUE_REGEX = Regex("""(?i)\b(token|access_token|refresh_token|deviceid)\b\s*[=:]\s*([^\s&"]+)""")
private val BEARER_REGEX = Regex("""(?i)\b(authorization)\b\s*:\s*bearer\s+([^\s"]+)""")
private val JWT_LIKE_REGEX = Regex("""(?i)\beyJ[\w\-_.]+""")
override fun sanitize(input: String): String =
input
.let { JSON_TOKEN_REGEX.replace(it) { "\"${it.groupValues[1]}\": \"$mask\"" } }
.let { KEY_VALUE_REGEX.replace(it) { "${it.groupValues[1]}=$mask" } }
.let { BEARER_REGEX.replace(it) { "${it.groupValues[1]}: Bearer $mask" } }
.let { JWT_LIKE_REGEX.replace(it) { mask } }
}
타마: “이제 토큰은 무조건 ***로만 남아.”
조이: “좋아. 이제야 로그를 열어도 안심이네.”
class NetworkLoggingInterceptor(
private val logger: Logger,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val startNs = System.nanoTime()
val requestBody = request.body?.let { body ->
Buffer().apply { body.writeTo(this) }.readString(Charset.forName("UTF-8"))
} ?: "No Body"
logger.debug(LogEvent.NETWORK, """
🏹 Request: ${request.method} ${request.url}
Body: $requestBody
""".trimIndent())
return runCatching {
val resp = chain.proceed(request)
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
val source = resp.body.source().apply { request(Long.MAX_VALUE) }
val respBody = source.buffer.clone().readString(Charset.forName("UTF-8"))
logger.debug(LogEvent.NETWORK, """
🛡️ Response: ${resp.code} ${resp.message} (${tookMs}ms)
URL: ${resp.request.url}
Body: $respBody
""".trimIndent())
resp
}.onFailure { e ->
logger.error(LogEvent.NETWORK, "☠️ Network request failed: ${e.message}")
}.getOrThrow()
}
}
비비는 의자에 몸을 기대며 잔잔한 미소를 지었다.
“이제 웬만한 건 전부 기록 남는다. 그리고 다시는 5,600개 폭탄 같은 거 안 만든다.”
조이는 노트북을 ‘딸깍’ 닫으며 말했다.
“내일은 우리가 말 안 해도 Crashlytics가 다 말해줄 거지.”
타마는 가방을 메고 일어섰다.
“그럼 오늘은 여기까지. 내일 대시보드엔 ERROR보다 INFO가 더 많길.”
밤 11시, 세 사람은 캠퍼스 문을 잠그고 조용한 복도로 걸어 나왔다.
데모데이 전날 밤, MulKkam의 로깅은 완성됐고, 앱은 고요히… 다음 기록을 기다리고 있었다.
📌 목적
📌 관점 분리
📌 로그 레벨
| Level | 설명 | 예시 |
|---|---|---|
| ERROR | 치명적 오류 | 크래시 |
| WARN | 잠재적 오류 | API 불안정 |
| INFO | 주요 이벤트 | 로그인 성공 |
| DEBUG | 상세 개발 로그 | 변수 값 출력 |
📌 빌드 배리언트
📌 민감정보 마스킹
📌 네트워크 로깅
📌 보존 정책