Upgrade to Pro — share decks privately, control downloads, hide ads and more …

느린 네트워크와 저사양 디바이스 사용자를 위한 웹뷰 로딩 시간 개선기

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →
Avatar for kmkim kmkim
November 02, 2026

느린 네트워크와 저사양 디바이스 사용자를 위한 웹뷰 로딩 시간 개선기

드로이드나이츠 2026 발표 슬라이드

Avatar for kmkim

kmkim

November 02, 2026

Other Decks in Programming

Transcript

  1. ✦ DroidKnights 2026 느린 네트워크와 저사양 디바이스 사용자를 위한 웹뷰

    로딩 시간 개선기 김기문 Delivery Hero · Software Engineer 2026.11.02 · ST센터, 서울
  2. ✦ Agenda 목차 01 배경 02 접근 1: URL 생성

    03 접근 2: 웹뷰 생성 04 접근 3: 웹 페이지 에셋 로컬 캐싱 05 결론 02
  3. CS 리포트 중… "고객 부재 등 상황에서 채팅 사용 어려움"

    • 로딩 시간 약 10초 이상 소요 (p90) 40초 소요 사례도 존재 (우측 클립, 2배속) • 배달 절차 진행 불가 → 주문 취소 및 손실 발생 05
  4. ✦ 배경 문제 상황 네이티브 솔루션에서 웹앱으로 전환 대기 시간이

    길어져 사용자 불편 확대 [이상] 개발 효율성 개선 [현실] 체감 성능 악화 06
  5. ✦ 배경 문제 상황 • “사용자의 53%가 페이지 로딩이 3초

    이상 걸리면 이탈” - 구글 리서치 • “페이지 로딩이 1초만 지연되어도 연간 16억 달러 손실 발생” - 아마존 • “로딩 속도를 1초 개선할 때마다 구매 전환율 최대 2% 상승” - 월마트 07
  6. ✦ 배경 사용자 특성 1. 저사양 디바이스 사용 비중 •

    RAM : 4GB 40~50% • CPU : 2.0GHz 옥타 코어 ▪ 저사양 디바이스 (40~50%) ▪ 중·고사양 디바이스 (50~60%) 08
  7. ✦ 배경 사용자 특성 2. 모바일 네트워크 의존성 • 미흡한

    인프라 • 이동 구간, 건물 내부 • 자연 재해 등 09
  8. ✦ 배경 로딩 시간 구성 총 소요 시간 = 네이티브

    소요 시간 + 웹 소요 시간 네이티브 소요 시간 시작 웹 소요 시간 웹뷰 URL 로드 콘텐츠 노출 11
  9. ✦ 접근 1: URL 생성 웹뷰 URL 로딩 loadUrl()에 들어갈

    URL을 미리 알 수 없다면? 예) 서버 의존 // 웹뷰 동적 생성 val webView = WebView(context) // URL 로드 webView.loadUrl("?") 14
  10. ✦ 접근 1: URL 생성 URL 생성 최적화 방안 •

    서버 의존 최소화 • 캐싱 • 사전 생성 15
  11. ✦ 접근 1: URL 생성 ① 서버 의존 최소화 URL

    생성하려고 서버 요청? 서버로부터 정보를 받아와야 URL을 완성할 수 있는 경우 정상 저속 Endpoint A Endpoint B Endpoint A Endpoint B 호출마다 왕복 시간 누적 16
  12. ✦ 접근 1: URL 생성 ① 서버 의존 최소화 호출

    횟수 최소화 Client 병렬 호출 적용 Client 로컬 상태 직접 조합 로컬 정보 Server A B 조합 데이터 시간 단축 URL 17
  13. ✦ 접근 1: URL 생성 ① 서버 의존 최소화 After

    Before 지역 코드 조회 region = kr 채팅 ID 조회 chatId = 123 채팅 초기화 채팅 ID 조회 chatId = 123 채팅 초기화 조합 kr.kmkim.com/chat/123#order=...  region = kr 조합 kr.kmkim.com/chat/123#order=... 18
  14. ✦ 접근 1: URL 생성 ② 캐싱 URL 재사용 유효

    기간 동안 URL 보존 → 서버 요청 횟수 감소 재진입 시 서버 요청 없이 반환 → URL 반환 속도 개선 페이지 재진입 Map(인메모리) DataStore 반환 = 네트워크 요청 0회 19
  15. ✦ 접근 1: URL 생성 ③ 사전 생성 페이지 진입

    이후 URL 생성 로딩 시간에 포함 페이지 진입 이전 URL 생성 로딩 시간에 미포함 버튼 클릭 ⏳ URL 생성 사전 생성 URL 즉시 이용 버튼 클릭 URL 로딩 로딩 완료 URL 로딩 로딩 완료 20
  16. ✦ 접근 1: URL 생성 ③ 사전 생성 • 주문

    수락 N초 이후, 채팅이 필요한 주문인지 검증 후 생성 시작 • 기준 ◦ 시스템 리소스 유휴 시점 ◦ 실제 진입 가능성이 높은 시점 ◦ 진입 전 URL 완성이 보장 가능한 시점 21
  17. 25

  18. ✦ 접근 2: 웹뷰 생성 실험 환경 디바이스 빌드 실행

    환경 Galaxy A32 Release Cold Start 측정 방식 범위 Perfetto 웹뷰 작업 한정 RAM 4GB CPU 2.0GHz 옥타 코어 실측 클럭 ≈ 1,800MHz 20회 측정, p90 27
  19. ✦ 접근 2: 웹뷰 생성 실험 웹뷰 생성자 호출 //

    Activity Trace.beginSection("WV_construct") val webView = WebView(this) Trace.endSection() 28
  20. ✦ 접근 2: 웹뷰 생성 결과 (메인 스레드) WebView(Context) 325

    ms WebViewChromium.constructor 10 ms ① WebViewFactory.getProvider() ② WebViewChromium.init 98 ms 213 ms Load WebView APK Provider init Browser process startup Per-instance 59 ms 35 ms 144 ms 68 ms loadNativeLibrary 24 ms Finish browser process start Profile AwContents 96 ms 28 ms 67 ms · native object 29
  21. ✦ 접근 2: 웹뷰 생성 결과 (메인 스레드) WebView(Context) 325

    ms WebViewChromium.constructor 10 ms ① WebViewFactory.getProvider() ② WebViewChromium.init 98 ms 213 ms 325 ms ≈ 20 frames 6.5 % 메인 스레드 점유 60fps 기준 ANR 입력 타임아웃 비중 325 / 16.7 ≈ 19.5 30
  22. ✦ 접근 2: 웹뷰 생성 Cold Start vs Warm Start

    Cold ① Provider ② Chromium init Browser process startup Warm Per-instance ② Chromium init Per-instance 31
  23. // Activity ✦ 접근 2: 웹뷰 생성 실험 웹뷰 생성자

    호출 + URL 로딩 Trace.beginSection("WV_construct") val webView = WebView(this) Trace.endSection() // url 로딩 Trace.beginSection("WV_loadUrl") webView.loadUrl("https://example.com") Trace.endSection() 32
  24. ✦ 접근 2: 웹뷰 생성 결과 loadUrl() 27 ms 메인

    스레드 기타 스레드 WebView(Context) 325 ms Page loading 266 ms Network + Browser + Renderer 프로세스 33
  25. ✦ 접근 2: 웹뷰 생성 디바이스 별 결과 Pixel 9a

    Galaxy A32 160 ms 325 ms Galaxy A13 1,000 ms 웹뷰 생성자 p90 (UI 스레드) 34
  26. ✦ 접근 2: 웹뷰 생성 시사점 ✓ ! loadUrl() 메인

    스레드 부담 ↓ 저사양 기기 로딩 시간 소모 + ANR 위험 35
  27. ✦ 접근 2: 웹뷰 생성 개선 방안 1. 생성 최적화

    (startUpWebView API) 2. 웹뷰 인스턴스 사전 생성? 36
  28. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 startUpWebView API

    무거운 웹뷰 초기화 작업들 중 일부 • 페이지 시작 전으로 • 백그라운드 스레드로 • 메인 스레드의 일들은 쪼개서 37
  29. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 WebView(this) ①

    WebViewFactory.getProvider() Load WebView APK Provider init loadNative Library Background Thread ② WebViewChromium.init Browser process startup Finish browser process start Per-instance Profile AwContents 39
  30. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 startUpWebView ①

    WebViewFactory.getProvider() Load WebView APK loadNative Library Provider init WebView(this) ② WebViewChromium.init startUpWebView Browser process startup Finish browser process start Per-instance Profile AwContents 40
  31. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 startUpWebView ①

    WebViewFactory.getProvider() Load WebView APK Provider init loadNative Library Background Thread WebView(this) ② WebViewChromium.init startUpWebView Browser process startup Finish browser process start Per-instance Profile AwContents Main Thread (Chunk) 41
  32. MyApplication.kt ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 val

    config = WebViewStartUpConfig .Builder(Executors.newSingleThreadExecutor()) .build() 앱 초기화 단계(예시)에서 startUpWebView 호출 Trace.beginAsyncSection("startUpWebView", 100) WebViewCompat.startUpWebView(this, config) { _ -> Trace.endAsyncSection("startUpWebView", 100) } 42
  33. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 페이지에서 웹뷰

    생성자 호출 ChatActivity.kt Trace.beginSection("WV_construct") val webView = WebView(this) Trace.endSection() 43
  34. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 (결과) App

    Start Main thread startUpWebView() Page Start 283 ms Start Chromium: total 41 ms Java object 9 ms WebView() 53 ms WebViewChromium.init 40 ms Per-instance 40 ms ` BG thread WebViewFactory.getProvider() 88ms WebView apk 38ms, Provider init 38ms 44
  35. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 (주의점) Case

    1. 완료 후 생성자 호출 BG startUpWebView() WebView() Main Case 2. 완료 전 생성자 호출 BG Main startUpWebView() 대기 WebView() 45
  36. ✦ 접근 2: 웹뷰 생성 ① 생성 최적화 (시사점) 적용

    전 (메인 스레드) 적용 후 (메인 스레드) ✓ 페이지에서 메인 스레드 부하 / 생성 시간 ↓ ✓ 여전히 페이지에서의 생성 시간 필요 46
  37. ✦ 접근 2: 웹뷰 생성 ② 사전 생성? startUpWebView로도 줄이지

    못한 시간, 웹뷰를 미리 만들어 두고 재사용하면? 예) 앱 시작 시점 웹뷰 생성 + 보관 인메모리 캐싱 페이지 진입 시점 보관된 웹뷰 사용 47
  38. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 웹뷰 캐싱을

    위한 홀더 정의 object WebViewHolder { @Volatile var webView: WebView? = null } 48
  39. // 페이지 시작 전 ✦ 접근 2: 웹뷰 생성 ②

    사전 생성 // ... config declaration ... WebViewCompat.startUpWebView(this, config) { _ -> Trace.beginSection("WV_prewarm") 앱 초기화 단계(단순 예)에서 val webView = WebView(MutableContextWrapper(context)) startUpWebView 호출 + 웹뷰 인스턴스 생성 및 캐싱 Trace.endSection() WebViewHolder.webView = webView } 49
  40. // ... Activity Layout Inflation ... Trace.beginSection("WV_construct") ✦ 접근 2:

    웹뷰 생성 val cached = WebViewHolder.webView ② 사전 생성 val webView = if (cached != null) { 캐싱된 웹뷰 사용 ?.baseContext = this (cached.context as? MutableContextWrapper) cached } else { WebView(this) // fallback } Trace.endSection() 50
  41. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 (결과) App

    Start Main thread Page Start startUpWebView() Java object Start Chromium WebView() WebViewChromium.init Per-instance ` BG thread WebViewFactory.getProvider() WebView apk, Provider init 51
  42. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 (결과) App

    Start UI thread WebView() startUpWebView() Start Chromium Page Start WebView init 0.1 ms ` BG thread WebViewFactory.getProvider() WebView apk, Provider init 52
  43. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 (시사점) 페이지

    진입 이후 웹뷰 생성 시간 325 ms 단순 생성자 호출 startUpWebView 사전 생성 53 ms ≈ 0 ms 53
  44. ✦ 접근 2: 웹뷰 생성 // 페이지 시작 이전 ②

    사전 생성 val webView = loadUrl() 호출 및 브릿지 통신으로 미리 완성 페이지 진입 전 웹뷰 생성 URL 로딩 WebView(MutableContextWrapper(this)) webView.loadUrl("https://example.com") 페이지 진입 // Initialize Bridge … 바로 표시 // Bridge Communication … 54
  45. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 (참고) 여건이

    따라준다면, 공식 API 활용 가능 Speculative Loading APIs 56
  46. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 문제점 앱에서

    웹뷰만 쓰는 것이 아님 ANR 우려? 58
  47. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 Q. 당장

    사용하지 않는 뷰를 메인 스레드에서 미리 생성하는 것이 타당한 설계인가? 59
  48. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 안정성 확보

    • ANR ◦ 앱 시작 시기 외, 메인 스레드 유휴 구간 활용 ◦ 지속적 관찰 필요 • 비활성화 방안 마련 ◦ Remote Config 60
  49. ✦ 접근 2: 웹뷰 생성 ② 사전 생성 A/B 테스트

    • 사용성 및 안전성 검증 결과 : 중, 저사양 디바이스 ANR 빈도 증가 • 비즈니스 관점에서 정말 필요하다고 판단될 때만 채택 61
  50. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 등장 배경

    네이티브 측에서 웹 페이지 로딩 시간을 단축할 수 있는가? 64
  51. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 등장 배경

    웹 페이지의 HTML, CSS, JS 파일들을 기기에 미리 다운로드? 65
  52. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView // 웹뷰에 URL 로드 webView.loadUrl("https://kmkim.com/chat/1000") 66
  53. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView CDN 서버 index.html GET /chat/1000 HTTP/1.1 Host: kmkim.com HTTP/1.1 200 OK Content-Type: text/html index.html 수신 67
  54. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView CDN 서버 index.html <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="/styles.css"> <script src="/vendor.js"></script> </head> <body> <div id="root"></div> <script src="/main.js"></script> </body> </html> 68
  55. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView CDN 서버 추가 네트워크 왕복 /styles.css index.html CDN 서버 CSS · JS /vendor.js GET CDN /main.js 69
  56. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView index.html CDN 서버 index.html CDN 서버 CSS · JS 실행 · 렌더링 <html> <head> <!-- ... --> </head> 파싱 DOM 생성 파싱 <body> <div id="root"></div> </body> </html> div#root 70
  57. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView DOM에 추가 div#root main.js CDN 서버 index.html CDN 서버 CSS · JS root.append( Header(), Messages(), Input() ) 컴파일 실행 header messages input 실행 · 렌더링 71
  58. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 웹 페이지가

    열리는 과정 WebView DOM div#root CDN 서버 index.html CDN 서버 CSS · JS header messages 렌더링 css 적용 input 실행 · 렌더링 72
  59. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 아이디어 WebView

    CDN 서버 index.html CDN 서버 디바이스에 미리 받아놓는다면? CSS · JS 실행 · 렌더링 73
  60. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 아이디어 WebView

    WebView 디바이스 /index.html 네트워크 왕복 X 웹 로딩 단축? index.html /styles.css 디바이스 CSS · JS 파일 시스템 /main.js, /vendor.js 실행 · 렌더링 74
  61. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 STEP 01 STEP 02 STEP 03 STEP 04 STEP 05 STEP 06 근무 대기 근무 시작 주문 대기 주문 수락 주문 픽업 주문 배달 파일 다운로드 완료까지 충분한 시간 확보 76
  62. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 data/data/<app_package>/files/customer-chat-assets/ release A 기존 사용 새 릴리즈 release B 메타데이터 3f9a…e1 8be2…07 ? a41d…9e 77
  63. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 서버로부터 버전 정보 확인 (예: WorkManager 활용 폴링) 서버 응답 디바이스 타겟 버전 : B 보유 버전 : A 동일? Yes No 다운로드 생략 B 다운로드 78
  64. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 �� 다운로드 여부 판단 원본 파일명 에셋 다운로드 해시값 release B index.html 3f9a…e1 main.js 8be2…07 vendor.js a41d…9e styles.css 5c06…b3 79
  65. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 정보 다운로드 여부 판단 에셋 다운로드 원본 파일명 main.js 예상 해시 동일? 8be2.. 다운로드 URL kmkim.com/8be2.. 파일 바이트 실제 해시 8be2..07 80
  66. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 받은 파일 SHA-256 에셋 다운로드 8be2… Y 저장 N 폐기 동일? 무결성 검증 서버 예상값 8be2… 81
  67. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 파일 명이 해시값이므로, 메타데이터로 인덱싱 Release B 에셋 다운로드 무결성 검증 메타데이터 메타데이터 READY 8be2…07 main.js ↔ 8be2…07 a41d…9e styles.css↔a41d…9e 3f9a…e1 /chat/* ↔ 3f9a…e1 파일 시스템 저장 82
  68. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 에셋 다운로드 무결성 검증 customer-chat-assets / release A release B 메타데이터 메타데이터 3f9a…e1 3f9a…e1 8be2…07 8be2…07 a41d…9e a41d…9e READY 파일 시스템 저장 83
  69. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 1단계: 에셋

    사전 다운로드 다운로드 여부 판단 에셋 다운로드 customer-chat-assets / release A release B READY 메타데이터 무결성 검증 ✕ 3f9a…e1 8be2…07 a41d…9e 파일 시스템 저장 84
  70. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 2단계: 에셋

    사용 준비 인덱스: 파일 접근을 위한, 런타임에 활용할 자료 구조 (원본 파일명 : File) 파일 시스템 메타데이터 인메모리 인덱스 main.js ↔ 8be2…07 main.js → File("/8be2…07") styles.css ↔ a41d…9e styles.css → File("/a41d…9e") … … /chat/* ↔ 3f9a…e1 /chat/* → File(/"3f9a…e1") 85
  71. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 2단계: 에셋

    사용 준비 <script src="/main.js"></script> 인메모리 인덱스 READY release B 인덱스 main.js → File("/8be2…07") styles.css → File("/a41d…9e") … /chat/* → File(/"3f9a…e1") 인메모리 CSS · JS 표 메타데이터 main.js → File(8be2…07) 3f9a…e1 styles.css → File(a41d…9e) HTML 표 8be2…07 a41d…9e /chat/* → File(3f9a…e1) 86
  72. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계: html

    서빙 WebView 네트워크 요청 X, 앱에서 직접 실행할 html 지정 디바이스 index.html WebView /chat/77 index.html 파일 시스템 디바이스 CSS · JS 서버 실행 · 렌더링 87
  73. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계: html

    서빙 /chat/1000 /chat/2048 /chat/* index.html 3f9a…e1 /chat/77 88
  74. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계: html

    서빙 ① 요청 ② 인덱스 탐색 ③ 콘텐츠 준비 /chat/1000 /chat/* ✓ 일치 File 3f9a…e1 ④ 웹뷰에 전달 ... HTML 문자열 WebView 89
  75. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계: html

    3단계: html서빙 서빙 ① 요청 ② 인덱스 탐색 ③ 콘텐츠 준비 /chat/1000 /chat/* ✓ 일치 File 3f9a…e1 ④ 웹뷰에 전달 ... HTML 문자열 HTML 변경 WebView 90
  76. index.html ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계:

    html 서빙 원본 html 상태 <head> <link rel="stylesheet" href="https://kmkim.com/styles.css"> </head> <body> <div id="root"></div> <script src="https://kmkim.com/main.js"> </script> </body> css, js 서빙 위해 변형 필요 91
  77. index.html ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계:

    html 서빙 절대 경로 변경 <head> <link rel="stylesheet" href="http://127.0.0.1:50000/styles.css"> </head> <body> <div id="root"></div> <script src="http://127.0.0.1:50000/main.js"> </script> </body> 원본 url → 로컬 호스트 92
  78. index.html ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계:

    html 서빙 상대 경로 base 고정 원본 url → 로컬 호스트 <head> <base href="http://127.0.0.1:50000/"> <script> window.NATIVE_CACHE_LOCALHOST_BASE = "http://127.0.0.1:50000"; </script> <link rel="stylesheet" href="http://127.0.0.1:50000/styles.css"> </head> <body> <div id="root"></div> <script src="http://127.0.0.1:50000/main.js"> </script> </body> 93
  79. index.html ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 3단계:

    html 서빙 변조 검사 추가 (integrity) HTML을 변형한 이유? <head> <base href="http://127.0.0.1:50000/"> <script> window.NATIVE_CACHE_LOCALHOST_BASE = "http://127.0.0.1:50000"; </script> <link rel="stylesheet" href="http://127.0.0.1:50000/styles.css" integrity="sha256-…" crossorigin="anonymous"> </head> <body> <div id="root"></div> <script src="http://127.0.0.1:50000/main.js" integrity="sha256-…" crossorigin="anonymous"> </script> </body> 94
  80. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 4단계: css,

    js 서빙 WebView 루프백 서버? 자기 자신에게 데이터를 보내고 받는 네트워크 환경 디바이스 index.html 디바이스 CSS · JS 실행 · 렌더링 95
  81. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 4단계: css,

    js 서빙 변환 index.html 디바이스 WebView 디스크 styles.css 127.0.0.1 main.js <head> <link rel="stylesheet" href="http://127.0.0.1:50000/styles.css"> </head> <body> <div id="root"></div> <script src="http://127.0.0.1:50000/main.js"> </script> </body> 96
  82. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 4단계: css,

    js 서빙 요청 GET /main.js HTTP/1.1 Host: 127.0.0.1:50000 Origin: https://kmkim.com/… WebView 루프백 서버 응답 HTTP/1.1 200 OK Content-Type: text/javascript Cache-Control: public, max-age=31536000, immutable Access-Control-Allow-Origin: * X-Content-Type-Options: nosniff 97
  83. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 응답 헤더

    4단계: css, js 서빙 ... Cache-Control: public, max-age=31536000, immutable 왜 루프백 서버를 활용하는가? ... WebViewClient .shouldInterceptRequest() 루프백 서버 초기 초기 반복 반복 응답 컴파일 실행 98
  84. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 V8 코드

    캐시 동작 JS 실행 엔진 ① JS 요청 서버 ③ JS 전달 V8 웹뷰 ② JS 응답 ④ 컴파일 코드 ⑤ 저장 HTTP 캐시 ⸻ V8 코드 캐시 99
  85. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 V8 코드

    캐시 동작 ① 요청 서버 ④ JS/캐시 전달 웹뷰 V8 ② 응답 ③ 캐시 사용? HTTP 캐시가 파일 변경 여부 판단 HTTP 캐시 ⸻ V8 코드 캐시 100
  86. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 V8 코드

    캐시 동작 서버 ① 요청 ③ JS 전달 WebView Client 웹뷰 V8 ② 응답 shouldInterceptRequest HTTP 캐시 ⸻ V8 코드 캐시 101
  87. 응답 헤더 ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱

    ... V8 코드 캐시 동작 Cache-Control: public, max-age=31536000, immutable ... ① 요청 루프백 서버 ③ JS + 캐시 전달 웹뷰 V8 ② 응답 ③ 캐시 사용 HTTP 캐시 ⸻ V8 코드 캐시 102
  88. LoopbackServer.kt fun start() { socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0) )

    savePort(socket.localPort) while (true) { serve(socket.accept()) } WebView 루프백 서버 } private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) } 103
  89. LoopbackServer.kt fun start() { socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0) )

    savePort(socket.localPort) while (true) { serve(socket.accept()) } WebView 루프백 서버 } private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) }  호스트 127.0.0.1 요청만 처리  지난 포트 재사용 캐시 재사용 104
  90. LoopbackServer.kt fun start() { socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0) )

    savePort(socket.localPort) while (true) { serve(socket.accept()) } WebView 루프백 서버 } private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) 동시 연결 8개 허용 if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) } 105
  91. LoopbackServer.kt fun start() { socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0) )

    savePort(socket.localPort) while (true) { serve(socket.accept()) } WebView 루프백 서버 부적절한 요청 거부 } private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) } 요청에 Body 존재 GET, HEAD, OPTIONS 읽기 요청이 아닌 경우 Origin 비교 허용된 origin만 필터링 106
  92. LoopbackServer.kt fun start() { socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0) )

    savePort(socket.localPort) while (true) { serve(socket.accept()) } } WebView 루프백 서버 인덱스 인메모리 main.js → File(8be2…07) private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) } styles.css → File(a41d…9e) … /chat/* → File(3f9a…e1) 인덱스에서 파일 탐색 없으면 404 107
  93. LoopbackServer.kt fun start() { WebView socket.bind( InetSocketAddress("127.0.0.1", savedPort ?: 0)

    ) savePort(socket.localPort) HTTP/1.1 200 OK while (true) { serve(socket.accept()) } Content-Type: text/javascript Cache-Control: public, max-age=31536000, immutable } Access-Control-Allow-Origin: * private fun serve(client: Socket) { if (active.size >= 8) return client.close() val req = parse(client) X-Content-Type-Options: nosniff if (req.hasBody) return reply(400) if (req.method !in GET_HEAD_OPTIONS) return reply(405) if (req.origin != expectedOrigin) return reply(404) val asset = index[req.path] ?: return reply(404) writeHeaders(200, asset) asset.file.inputStream().copyTo(client.out) } 루프백 서버 헤더 쓰기 파일 시스템 css/js 소켓 통해 전송 108
  94. // 웹뷰에 URL 로드 webView.loadUrl("https://kmkim.com/chat/1000") webView.loadDataWithBaseURL( "https://kmkim.com/chat/1000", ✦ 접근 3:

    웹 페이지 에셋 로컬 캐싱 5단계: 웹 페이지 실행 modifiedHtml, // 3단계에서 변형한 html 문자열 "text/html", "utf-8", null ) 109
  95. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 CORS :

    루프백 서버를 통해 HTML을 가져오지 않은 이유 (1) 1 Origin: 127.0.0.1 Activity webView.loadUrl( "http://127.0.0.1..." ) 2 JS 코드가 서드 파티 호출 main.js fetch( "https://<grafana>/..." ) 3 서드 파티 측 요청 거부 Grafana 허용 Origin ✓ https://kmkim.com/... ✕ http://127.0.0.1... blocked by CORS policy: origin 127.0.0.1 not allowed 110
  96. WebView 루프백 서버 ✦ 접근 3: 웹 페이지 에셋 로컬

    캐싱 보안 라이더 앱 포트 스쿼팅 루프백 서버의 포트를 통해 다른 앱도 접근 가능 타앱 127.0.0.1:50000 악성 앱 111
  97. WebView ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 보안

    포트 스쿼팅 루프백 서버의 포트를 통해 다른 앱도 접근 가능 악성 웹 에셋 실행 우려 루프백 서버 타 앱의 포트 선점 회피  → 비어있는 랜덤 포트 사용 서버 먼저 열고, 포트 확정 후 URL 생성  타 Origin의 에셋 요청 방지 → Origin 확인, 허용 호스트 이외 모두 거부  파일 해시 이중 검증 → 다운로드 시점 + 실행 시점 112
  98. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 <script src="http://127.0.0.1:50000/main.js"

    integrity="sha256-8be2..07"> </script> 해시 이중 검사  sha256 = 8be2…07  CDN 서버 ?  ① SHA-256    앱 디스크 루프백 서버 ② integrity  ? WebView 릴리즈 저장 여부 실행 여부 파일에서 추출한 해시 == 예상 해시? 파일에서 추출한 해시 == integrity 해시? 113
  99. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 해시 이중

    검사 : 루프백 서버를 통해 HTML을 가져오지 않은 이유 (2) �� 악성 앱 WebView.loadDataWithBaseURL 변조 → HTML 직접 전달 HTML?      CDN 서버 앱 디스크 루프백 서버 WebView 114
  100. index.html <head> <base href="http://127.0.0.1:50000/"> <script> window.NATIVE_CACHE_LOCALHOST_BASE = "http://127.0.0.1:50000"; </script> <link

    rel="stylesheet" href="http://127.0.0.1:50000/styles.css" integrity="sha256-…" crossorigin="anonymous"> </head> <body> <div id="root"></div> <script src="http://127.0.0.1:50000/main.js" integrity="sha256-…" crossorigin="anonymous"> </script> </body> HTML을 변형한 이유? 루프백 서버 활용 보안 115
  101. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 최종 구조

    앱 프로세스 앱 HTML loadDataWith BaseUrl() 파일 시스템 WebView JS · CSS 로컬 웹 서버 소켓으로 응답 116
  102. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 한계점 다루지

    않은 영역  Origin 위조 가능 (kmkim.com/chat/로 요청) 괜찮은 이유  공개 파일 (PII 없음)  해시 + SRI  HTML은 서버로 호스팅하지 않음 117
  103. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 적용 결과

    • 제한된 브랜드, 지역 테스트 • 총 로딩 시간 감소 118
  104. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 반전? 실험성에

    그침 ∵ 웹 측의 코드 개선, 웹 소요 시간이 수용 가능한 수준으로 감소 (p90 기준, 웹 로딩 3초 이내) 119
  105. ✦ 접근 3: 웹 페이지 에셋 로컬 캐싱 고려 사항

    0 웹 측의 개선 기다려보기 1 보안 부서와 협의 필수 2 PII를 다루는 페이지라면, 더욱 신중히 판단 120
  106. * 실 사용자 제보 클립 ✦ 결론 최종 개선 [p90]

    약 4초 ① URL 생성 채택 ② 웹뷰 생성 일부 채택 ③ 에셋 로컬 캐싱 채택 보류 + 웹앱 코드 개선 122
  107. ✦ 결론 교훈 • 네이티브 → 웹 전환은 신중히 고민

    후 결정 그 반대도 마찬가지, 다른 기술적인 결정도 마찬가지 • 웹뷰 생성에는 많은 비용 소요 저사양 디바이스의 앱 사용성에 영향을 줄 수 있음 • 정말로 팀의 책임인지 판단 아니라도, 비즈니스를 위해 해야 하는지? 123
  108. 참고 자료 [Android] Optimize WebView Startup [Android] Manage WebView Memory

    [Android] WebView Memory [Android] Speculative Loading [V8] Improved Code Caching [V8] Code Caching For Devs 124