앱 서비스 로깅 전략 이야기 (feat. 로그 전쟁 이야기)

GongBaek·2025년 8월 8일
post-thumbnail

📕 데모데이 전날, 우테코 캠퍼스의 로그 전쟁

“무슨 작업하기도 바쁜데, 로깅 전략 문서까지 만들래…”

조이는 모니터에 턱을 괴고 투덜거렸다.

— 캠퍼스는 데모데이 전날답게 소음이 리듬처럼 섞였다. 클릭, 타닥, 한숨, 그리고 누군가의 “어-!” 라는 짤막한 비명.

비비는 빈 아메리카노 컵의 빨대를 입에 물고 들어오며 말했다.

“안 만들면 나중에 앱 터졌을 때 왜 터졌는지 아무도 몰라. Crashlytics도 있고 Analytics도 있는데, 예쁘게 써야지.

이건 우리 물깜 서비스에서의 로깅 전략이야.

타마가 옆자리에서 안스를 켜고 중얼거렸다.

“그럼 레벨부터 박자. ERROR, WARN, INFO, DEBUG. 그~냥 싹싹김치.”

“그냥 println 쓰면 안 돼?” 조이가 뒤로 젖힌 의자에서 슬쩍 도발하자,

비비는 눈을 흘기며 싸늘하게 말한다.

“println은… 레아가 캠퍼스에 왔다가 친구한테만 ‘나 왔어~’ 하는 거랑 같아.

출석부에도 안 찍히고 기록도 안 남아. 나중에 코치가 ‘레아 오늘 왔었어?’ 물어보면 아무도 증명 못 해.”


0️⃣ Firebase 세팅 — 기록을 남길 도화지

비비: 로깅 얘기 전에, 해야 할 일들이 있어!

  1. Firebase Console 프로젝트 생성 → Android 앱 등록(패키지명 정확히)
  2. google-services.json 다운로드 → android/app/ 에 저장
  3. 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"
            )
        }
    }
}
  1. MulKkamApp에서 트리 심기
// 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)
        }
    }
}

비비가 꺄르르 손뼉을 쳤다. “좋아. 이제 찍으면 남는다.”

조이가 오랑이처럼 피식 웃었다. “이제 변명 대신 증거로 말할 수 있네. 다 죽었다.”


1️⃣ 1차 PR — 사전 세팅하기 (모델·인터페이스·Tree·Logger 생성)

타마: “형식부터 맞추자. 시간은 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 }

2️⃣ 5600개 로그 폭탄 사건 — DEBUG 분기 없음

다음 날 아침, 조이가 Firebase Analytics 콘솔을 보고 소리쳤다.

“야… 네트워크 이벤트가 5600개 찍혔어. 하루 만에. 우리 아직 정식 출시도 안했는데 ;”

비비: “아… DEBUG 로그까지 전부 Analytics로 보냈네. 요청·응답 하나하나가 사용자 이벤트로 올라갔어.”

  • 빌드 배리언트 분기 없이 모든 로그 전송
  • DEBUG/INFO까지 운영 데이터 오염
  • 하루 만에 5600건 폭주

3️⃣ Build Variant 분기 — 폭탄 해체

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))
}

4️⃣ 토큰 유출 사건 — 민감정보와의 전쟁

네트워크 로그 점검 중, 타마: “야… 인증 토큰이 로그에 그대로 찍혀 있는데?”

조이: “진짜? 그거 털리면 끝장인데?”

비비가 노트북 화면을 돌려줬다. 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 } }
}

타마: “이제 토큰은 무조건 ***로만 남아.”

조이: “좋아. 이제야 로그를 열어도 안심이네.”


5️⃣ 네트워크 로깅

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의 로깅은 완성됐고, 앱은 고요히… 다음 기록을 기다리고 있었다.


📙 MulKkam 로깅 전략 정리 (우리 팀 합의안)

📌 목적

  • 안정성 확보
  • 원인 분석
  • 서비스 개선

📌 관점 분리

  • 개발자: ERROR/WARN/DEBUG
  • 기획: INFO

📌 로그 레벨

Level설명예시
ERROR치명적 오류크래시
WARN잠재적 오류API 불안정
INFO주요 이벤트로그인 성공
DEBUG상세 개발 로그변수 값 출력

📌 빌드 배리언트

  • DEBUG: 콘솔 로그만
  • RELEASE: Analytics/Crashlytics 전송

📌 민감정보 마스킹

  • 토큰, deviceId, Authorization, JWT

📌 네트워크 로깅

  • 요청/응답/소요 시간 기록
  • 실패 원인 ERROR로 출력
  • 자동 마스킹 적용

📌 보존 정책

  • Crashlytics: 90일
profile
Junior Android Developer

0개의 댓글