모든 로그를 JSON으로 파싱하면 안 될까???

2026. 1. 21. 02:58·Android

안드로이드 개발을 하면서 로그캣을 통해 네트워크 로깅은 필수 입니다. 하지만 있는 그대로 JSON을 출력해보면 가독성이 매우 좋지 않죠..

때문에 네트워크 통신 로그를 가독성 좋게 파악하고 싶어서 HttpLoggingInterceptor에 JSON Pretty Print 기능을 추가했었습니다. 개발 생산성은 확실히 좋아졌지만 문득 "이거 성능상 문제 없을까?"라는 의문이 들었습니다.

이번 글에서는 작은 의문에서 시작해 성능을 95% 개선하기까지의 과정을 공유합니다.

뭔가..뭔가 불편함…

처음 작성한 로깅 코드는 이렇습니다.

// NetworkModule.kt
HttpLoggingInterceptor { message ->
    val log = runCatching {
        val jsonElement = json.decodeFromString(JsonElement.serializer(), message)
        json.encodeToString(JsonElement.serializer(), jsonElement)
    }.getOrElse { message }

    Timber.tag("okhttp").d(log)
}

얼핏 보면 괜찮아 보입니다. JSON이면 예쁘게 출력하고 아니면 원본을 출력하니까요.

그런데... 문제가 뭘까요?

HttpLoggingInterceptor가 전달하는 message에는 다음과 같은 것들이 포함됩니다

  • JSON 응답 본문 (우리가 원하는 것)
  • HTTP 헤더 (Content-Type: application/json) 🙅🏻‍♂️
  • HTML 페이지 (<!DOCTYPE html>...) 🙅🏻‍♂️
  • 일반 텍스트 (Connection established) 🙅🏻‍♂️

모든 메시지에 대해 JSON 파싱을 시도하면 JSON이 아닌 경우 매번 예외가 발생하고 catch로 빠집니다. HTTP 헤더만 해도 요청/응답마다 수십 줄씩 찍히는데 이게 누적되면...?

1단계: 문제 크기 측정하기

"추측하지 말고 측정하라"는 말을 떠올리며 벤치마크 코드를 작성했습니다.

// 2,000회 반복 테스트
@Test
fun `최적화 전 성능 측정`() {
    val iterations = 2000

    // 케이스 1: 실제 JSON
    val validJson = """{"userId": 123, "name": "홍길동"}"""

    // 케이스 2: HTTP 헤더 (가장 빈번)
    val header = "Content-Type: application/json; charset=utf-8"

    // 케이스 3: HTML 응답
    val html = "<!DOCTYPE html><html>...</html>"

    measureTimeMillis { /* 테스트 */ }
}

결과는?

========== BENCHMARK RESULTS (Before Optimization) ==========
Iterations: 2000
Valid JSON Time: 84ms
Plain Text (Header) Time: 11ms
HTML Content Time: 4ms
==============================================================
  • JSON이 아닌 문자열을 파싱하려다 실패하는 비용이 케이스당 4~11ms
  • HTTP 헤더는 모든 요청/응답마다 수십 줄씩 발생
  • 만약 100줄이면? 1초 가까운 딜레이 발생 가능!

그럼 어떻게 하는데

JSON 파싱은 꽤나 비싼 작업입니다. 그렇다면 "파싱할 가치가 있는지 먼저 확인"하면 어떨까요?

JSON은 항상 { 또는 [로 시작합니다. 이 간단한 규칙을 활용해봅시다!

private fun createSmartLogger(json: Json) = HttpLoggingInterceptor.Logger { message ->
    val trimmed = message.trim()

    // JSON일 가능성이 있는 경우만 파싱 시도
    val isPotentialJson = trimmed.startsWith("{") || trimmed.startsWith("[")

    val log = if (isPotentialJson) {
        runCatching {
            val jsonElement = json.decodeFromString(JsonElement.serializer(), trimmed)
            json.encodeToString(JsonElement.serializer(), jsonElement)
        }.getOrElse { message }
    } else {
        message // JSON 아니면 그냥 원본 출력
    }

    Timber.tag("okhttp").d(log)
}

startsWith("{") || startsWith("[") 를 추가해 JSON의 가능성 자체를 체크하도록 했습니다.

또한 HTTP헤더, HTML같은 불필요한 정보들은 파싱 자체를 시도하지 않도록 했어요. 최종의 최최종으로 JSON이 아니라면 원본을 출력합니다.

2단계: 진짜 효과 있나 보기

동일한 조건으로 다시 테스트했습니다.

========== BENCHMARK RESULTS (After Optimization) ==========
Iterations: 2000
Valid JSON Time: 82ms
Plain Text (Header) Time: 0ms
HTML Content Time: 0ms
=============================================================

생각보다 성능이 좋아서 놀랐습니다. JSON 처리 속도는 그대로 (82ms, 사전 검사 비용 미미)였어요. 오히려 startsWith() 의 단계가 큰 영향을 주지 않았습니다. 그리고 두가지 경우의 확실한 이득을 가져왔어요.

  • Header 처리: 100% 개선 (11ms → 0ms)
  • HTML 처리: 100% 개선 (4ms → 0ms)

3단계: 최최최종 검증

"혹시 우연은 아닐까?" 싶어서 더 정밀한 테스트를 진행했습니다.

좀더 강한 테스트와 엣지 케이스를 추가했어요.

  • 반복 횟수: 50,000회
  • JVM Warm-up 적용
  • GC 고려
  • Malformed JSON 케이스 추가
@Test
fun `정밀 벤치마크 - 50000회 반복`() {
    // Warm-up
    repeat(1000) { /* ... */ }

    System.gc() // GC 실행
    Thread.sleep(100)

    // 실제 측정
    val iterations = 50000
    // ...
}

최종 결과

----------------------------------------------------------------------------------
Case            | Before (ms)  | After (ms)   | Improv (%)
----------------------------------------------------------------------------------
Plain Text      | 147          | 7            | 95       %
Valid JSON      | 99           | 94           | 5        %
Malformed JSON  | 180          | 174          | 3        %
-----------------------------------------------------------------------------------
  1. Plain Text 처리에서 95%의 성능 향상
  2. JSON 파싱에는 영향 없음 (오히려 미세하게 개선)

오늘의 교훈..

1. try-catch는 예외 처리용이지 흐름 제어용이 아니다

// 예외를 흐름 제어에 사용
runCatching {
    parseJson(unknownString) // 매번 실패할 수 있음
}.getOrElse { defaultValue }

// 사전 검사로 예외 회피
if (isValidFormat(unknownString)) {
    parseJson(unknownString)
} else {
    defaultValue
}

 

2. 성능 최적화는 측정에서 시작한다

"이게 느릴 것 같은데?"라는 추측보다 "얼마나 느린지 측정해보자"

 

3. 작은 개선이 큰 차이를 만든다

단순한 startsWith() 검사 하나로 95%의 성능 개선 달성..

마치며

처음에는 "로그나 편하게 보자"는 단순한 생각이었습니다. 하지만 작은 의문을 놓치지 않고 측정하고 개선하면서 성능 최적화의 본질을 배울 수 있었습니다.

여러분도 코드를 작성하면서 "이게 괜찮을까?" 싶은 부분이 있다면 추측하지 말고 직접 측정해보는건 어떨까요? 매우 추천드립니다👍🏻

긴 글 읽어주셔서 감사합니다. 질문이나 피드백은 언제든 환영입니다! 🙌

참고 자료

  • OkHttp - HttpLoggingInterceptor 공식 문서
  • Kotlinx Serialization - JSON 공식 가이드
  • Android Developers - 네트워크 프로파일링

'Android' 카테고리의 다른 글

AGP 9.0 마이그레이션, 뭐가 달라졌는데요?  (0) 2026.01.21
Android CI 빌드 속도 1분대로 줄여보기  (0) 2026.01.06
OkHttp Authenticator에서 runBlocking 없이 토큰 갱신하기  (0) 2025.11.27
Credential Manager로 로그인이 안돼요. 이유는 몰라요  (2) 2025.11.18
[Android] 앱을 배포해 봅시다.  (0) 2025.10.02
'Android' 카테고리의 다른 글
  • AGP 9.0 마이그레이션, 뭐가 달라졌는데요?
  • Android CI 빌드 속도 1분대로 줄여보기
  • OkHttp Authenticator에서 runBlocking 없이 토큰 갱신하기
  • Credential Manager로 로그인이 안돼요. 이유는 몰라요
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (27) N
      • Android (22) N
        • Compose (11) N
        • Jetpack (2)
      • Kotlin (2)
      • 외부 활동 (3)
        • 우아한테크코스 8기 (3)
  • 블로그 메뉴

    • 홈
    • 안드로이드
    • 태그
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    coroutine
    runcatching
    build-logic
    DisposableEffect
    jetpack
    LaunchedEffect
    우테코
    Gradle
    compose
    Android
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
모든 로그를 JSON으로 파싱하면 안 될까???
상단으로

티스토리툴바