이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

728x90
반응형
SMALL

설정 화면에 텍스트 입력 칸을 하나 만들었습니다. 값은 DataStore에 저장하고, 화면은 그 값을 collectAsState()로 받아 씁니다. 교과서적인 단방향 데이터 흐름입니다.

영어로 hello를 칩니다. 잘 됩니다. 지우고 다시 칩니다. 잘 됩니다. 배포합니다.

그리고 며칠 뒤 한글로 "가나"를 쳐 봅니다.

입력한 것 : 가나
칸에 남은 것 : ㄱㅏㄴㅏ

자음과 모음이 합쳐지지 않고 낱개로 확정됩니다. 백스페이스도 자모 단위로 지워집니다. 저는 이 상태로 앱을 스토어에 내보냈고, 그 입력 칸은 앱의 핵심 기능이었습니다.

원인 — 한 글자마다 디스크를 한 바퀴 돈다

문제의 코드는 이렇게 생겼습니다.

// ❌ 값의 주인이 DataStore 다
val settings by settingsVm.settings.collectAsState()

OutlinedTextField(
    value = settings.customText,
    onValueChange = { settingsVm.setCustomText(it) },
)

한 글자를 칠 때마다 이 경로를 돕니다.

키 입력 → onValueChange → ViewModel → DataStore 쓰기(디스크)
       → Flow 방출 → collectAsState → 리컴포지션 → value 갱신

영어에서는 이 왕복이 늦어도 티가 안 납니다. h는 그냥 h이고, 늦게 돌아와도 h입니다.

한글은 다릅니다. 한글 입력은 "조합 중"이라는 중간 상태를 거칩니다. 처럼 이미 화면에 있는 글자가 다음 입력으로 바뀌는 과정이고, 이 조합 상태는 IME가 입력 필드와 연결된 채로 들고 있습니다.

그런데 value비동기 저장소에서 돌아오는 값이면, 한 글자마다 필드의 값이 외부에서 통째로 교체됩니다. IME 입장에서는 자기가 조합하던 글자가 매번 발밑에서 사라지는 셈이라 조합 상태가 끊깁니다. 끊긴 조합은 인 채로 확정되고, 다음 도 따로 확정됩니다. 그래서 ㄱㅏㄴㅏ가 됩니다.

이 버그는 영어로 테스트하면 100% 통과합니다.
조합 과정이 없는 문자에서는 왕복 지연이 그냥 "약간 늦게 반영됨"으로만 보입니다. 한글·일본어·중국어에서만 터집니다. 그래서 영어 문서와 영어 샘플만 보고 만든 코드에는 이 함정이 그대로 남아 있습니다.

해결 — 입력값은 화면이 들고, 저장은 따로 내보낸다

고치는 원칙은 한 줄입니다. 입력 중인 값의 주인은 화면이어야 합니다. 저장은 그대로 나가되, 필드가 그 결과를 기다리지 않게 만듭니다.

// ✅ 입력값은 화면이 직접 들고 있는다
var customInput by rememberSaveable { mutableStateOf<String?>(null) }

OutlinedTextField(
    value = customInput ?: settings.customText,
    onValueChange = {
        customInput = it              // 화면이 즉시 반영 — IME 조합이 안 끊긴다
        settingsVm.setCustomText(it)  // 저장은 따로 나간다
    },
    singleLine = true,
)

String?null이 하는 일

null"아직 사용자가 아무것도 안 쳤다"는 뜻입니다.

상태value의미
customInput == nullsettings.customText저장된 값을 보여준다 (첫 진입, 로드 완료 시점 반영)
customInput != nullcustomInput사용자가 한 글자라도 쳤다 — 로컬이 주인

빈 문자열("")로 초기화하면 안 되는 이유가 여기 있습니다. DataStore가 값을 늦게 내주는 첫 프레임에서 저장돼 있던 글자가 빈 칸으로 덮여 보입니다. 사용자에게는 설정이 날아간 것처럼 보입니다.

remember가 아니라 rememberSaveable

remember만 쓰면 화면 회전이나 시스템의 프로세스 정리에서 입력 중이던 값이 사라집니다. rememberSaveableBundle에 실려 살아남습니다. 입력 칸에는 기본으로 이쪽을 씁니다.

같은 함정이 있는 자리들

DataStore만의 문제가 아닙니다. value가 비동기 왕복을 거쳐 돌아오면 전부 같습니다.

  • Room — 입력할 때마다 DB에 쓰고 Flow로 다시 받는 구조
  • 서버 저장 — 자동 저장 필드. 네트워크 지연이라 증상이 가장 심합니다
  • ViewModel StateFlow — 저장소가 없어도 debounce·distinctUntilChanged·stateIn 같은 연산자가 중간에 끼면 같은 일이 벌어집니다
  • 부모에서 내려주는 상태 호이스팅 — 값이 여러 단계를 거쳐 오면서 프레임이 밀리는 경우
판별 기준은 하나입니다.
"onValueChange에서 넣은 값이 같은 프레임에 그대로 value로 돌아오는가?"
중간에 비동기가 한 번이라도 끼면 조합형 문자가 깨질 자리입니다.

확인하는 법

도구가 필요 없습니다. 앱을 켜고 텍스트 칸에 한글을 치면 끝입니다. 문제는 그걸 안 해 본다는 것입니다.

체크리스트로 두면 좋습니다.

  • 입력 칸마다 "가나다"를 실제로 쳐 본다 — 붙여넣기 말고 자판으로. 붙여넣기는 조합 과정이 없어 통과합니다
  • 중간 글자를 백스페이스로 지웠다가 다시 친다 — 조합 상태가 가장 잘 깨지는 지점입니다
  • 빠르게 연타해 본다 — 저장이 느릴수록 증상이 선명합니다
  • 화면을 회전시킨다 — rememberSaveable 여부가 여기서 드러납니다

UI 테스트로 잡으려는 시도는 권하지 않습니다. Compose 테스트의 텍스트 입력은 조합 과정을 거치지 않고 값을 밀어 넣는 방식이라, 사람이 손으로 치는 것과 경로가 다릅니다. 초록불이 나와도 손으로 치면 깨집니다.

정리

  • Compose TextFieldvalue비동기 저장소 왕복에 맡기면 한글 자모가 흩어집니다.
  • 원인은 성능이 아니라 IME 조합 상태가 매 글자 끊기는 것입니다.
  • 입력값은 rememberSaveable화면이 들고, 저장은 따로 내보냅니다.
  • null 초기값으로 "아직 안 침"과 "빈 값"을 구분합니다.
  • 텍스트 입력 칸이 있으면 반드시 한글로 쳐 보세요. 영어 테스트는 이 버그를 절대 못 잡습니다.

자주 묻는 것

매 글자 저장하는 게 문제 아닌가요? debounce를 걸면 되지 않나요?

debounce저장 횟수를 줄일 뿐, value의 주인이 여전히 저장소면 조합은 똑같이 끊깁니다. 오히려 지연이 들쭉날쭉해져 증상이 불규칙해집니다. 두 가지는 별개의 개선이고, 먼저 고칠 것은 값의 주인입니다. 주인을 화면으로 옮긴 뒤에 저장 쪽에 debounce를 거는 것은 좋습니다.

TextFieldValue를 쓰면 해결되나요?

TextFieldValue는 커서 위치·선택 영역까지 같이 다루는 타입이라 관리할 상태가 늘어납니다. 원인이 "값이 외부에서 교체된다"는 것이므로, 타입을 바꾸는 게 아니라 주인을 바꿔야 풀립니다.

단방향 데이터 흐름 원칙에 어긋나는 것 아닌가요?

저장의 진실 공급원은 여전히 저장소 하나입니다. 달라진 것은 입력 중인 임시 상태를 화면이 들고 있다는 점뿐이고, 이건 IME처럼 프레임 단위로 이어져야 하는 상태에 대한 정상적인 처리입니다. 아키텍처를 지키다 글자가 깨지면 지킬 이유가 없습니다.

728x90
반응형
LIST
728x90
반응형
SMALL

앱 안에서 내 앱이 저장한 사진만 골라 보여 주는 갤러리 화면이 있었습니다. 잘 돌아갔습니다. 실기기에서도, 에뮬레이터에서도, 스토어에 나간 뒤에도 아무 문제가 없었습니다.

그런데 로보 테스트를 한 번 돌렸더니 이게 나왔습니다.

android.database.sqlite.SQLiteException: no such column: relative_path (code 1)
  SELECT ... FROM images WHERE (relative_path LIKE ?)
→ Force finishing activity com.example.myapp/.MainActivity

갤러리 탭을 누르는 순간 앱이 강제 종료됩니다. 조건은 하나, Android 9 이하 기기입니다.

원인 — 컬럼에도 API 레벨이 있다

MediaStore.MediaColumns.RELATIVE_PATHAPI 29(Android 10)에서 생긴 컬럼입니다. Scoped Storage가 들어오면서 절대경로 대신 쓰라고 만들어진 것입니다.

문제는 이 상수를 쓰는 코드가 구형 기기에서도 컴파일도 되고 실행도 된다는 점입니다. RELATIVE_PATH는 그냥 "relative_path"라는 문자열 상수이고, 그 문자열은 어느 API 레벨에서든 멀쩡히 존재합니다. Lint도 조용합니다.

터지는 곳은 훨씬 뒤입니다. 그 문자열이 ContentResolver.query()selection에 실려 미디어 DB로 넘어가고, 그 DB에 해당 컬럼이 없어서 SQLite가 예외를 던집니다. 컴파일 타임 안전장치가 하나도 걸리지 않는 종류의 API 경계입니다.

더 오래 안 들키는 쪽은 저장이다
저는 저장하는 쪽에도 같은 컬럼을 분기 없이 넣고 있었습니다. 이쪽은 죽지 않습니다. 구형 기기에서 RELATIVE_PATH조용히 무시되고, 사진이 내가 지정한 폴더가 아니라 기본 위치에 떨어집니다. 그러면 조회 조건과 어긋나 앱 갤러리에 아무것도 안 보입니다. 크래시가 나는 쪽보다 이쪽이 훨씬 오래 숨습니다.

왜 아무도 못 잡았나

이 크래시는 Play Console vitals에도 Crashlytics에도 0건이었습니다. 코드가 실제로 깨져 있는데도 그랬습니다. 이유는 단순합니다.

확인 경로결과이유
실사용자 지표0건90일 사용자 51명이 전원 API 29 이상이었다
내 실기기재현 안 됨테스트 폰이 Android 10(API 29)
로보 테스트(가상 API 26)첫날 바로 검출minSdk에서 실제로 앱을 열어 봤다

지표가 깨끗한 것은 코드가 맞다는 뜻이 아니라, 아직 아무도 그 자리를 밟지 않았다는 뜻이었습니다. 내일 Android 9 사용자가 설치하면 그대로 깨집니다. minSdk를 26으로 선언해 뒀다는 것은 그 기기에서 동작한다고 스토어에 약속했다는 의미입니다.

선언한 minSdk에서 한 번은 돌려 보세요. 클라우드 테스트가 없어도 됩니다. API 26-28 재현은 로컬 에뮬레이터로 충분합니다 — Android 9(API 28) AVD를 하나 만들어 두고, 저장소·미디어를 건드리는 화면만 훑어도 이런 부류는 잡힙니다.

고친 방식 — 질의를 SDK로 가르고, 함수로 뽑는다

조회 조건을 만드는 부분을 sdkInt를 인자로 받는 순수 함수로 분리했습니다.

/** 사진이 저장되는 자리. 조회와 저장이 같은 값을 봐야 하므로 한 곳에 둔다. */
const val EVIDSNAP_DIR = "DCIM/EvidSnap"

internal fun galleryQuery(sdkInt: Int): Pair<String, Array<String>> =
    if (sdkInt >= Build.VERSION_CODES.Q) {
        "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?" to arrayOf("$EVIDSNAP_DIR/%")
    } else {
        @Suppress("DEPRECATION")
        "${MediaStore.Images.Media.DATA} LIKE ?" to arrayOf("%/$EVIDSNAP_DIR/%")
    }

호출부는 이렇게 됩니다.

val (selection, selectionArgs) = galleryQuery(Build.VERSION.SDK_INT)
contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection, selection, selectionArgs, sortOrder
)?.use { cursor -> /* ... */ }

여기서 걸리기 쉬운 세 가지

지점내용
% 접두이게 없으면 한 건도 안 걸립니다. DATA/storage/emulated/0/DCIM/EvidSnap/... 같은 절대경로라, RELATIVE_PATH처럼 DCIM/으로 시작하지 않습니다
DATA deprecatedAPI 29부터 deprecated지만 pre-Q에서는 이것뿐입니다. 그쪽 분기에서만 쓰고 @Suppress를 답니다
경로 상수화폴더 문자열을 상수 하나로 모읍니다. 조회와 저장이 다른 문자열을 보면 아무 에러 없이 목록만 비어 보입니다

저장 쪽도 대칭으로 갈라야 한다

읽기만 고치면 절반입니다. 저장 쪽은 이렇게 갈랐습니다.

  • API 29 이상: RELATIVE_PATH + IS_PENDING을 그대로 사용
  • API 28 이하: Environment.getExternalStoragePublicDirectory로 폴더를 잡고 mkdirs()로 직접 만든 뒤 MediaColumns.DATA에 절대경로를 넣습니다. 미디어스토어가 폴더를 만들어 주지 않습니다.

참고로 바로 옆의 IS_PENDING은 원래부터 >= Q로 막혀 있었고, 매니페스트에도 WRITE_EXTERNAL_STORAGEmaxSdkVersion="28"이 붙어 있었습니다. 즉 pre-Q를 지원할 의도는 있었는데 구현만 빠진 것이었습니다. "SDK 분기를 한 군데 했으면 다 했겠지"는 근거가 안 됩니다.

경계값은 테스트로 못 박는다

sdkInt를 인자로 뺀 진짜 이유가 이것입니다. Build.VERSION.SDK_INT는 프레임워크 상수라 유닛 테스트 런타임에서 바꿀 수 없습니다. 인자로 받으면 경계 양쪽을 그냥 호출해서 확인할 수 있습니다.

@Test
fun `pre-Q 는 DATA 절대경로로 조회한다`() {
    val (selection, args) = galleryQuery(28)
    assertTrue(selection.startsWith("_data"))
    assertTrue(args[0].startsWith("%/"))   // 절대경로라 앞에 % 가 있어야 한다
}

@Test
fun `Q 이상은 RELATIVE_PATH 로 조회한다`() {
    val (selection, _) = galleryQuery(29)
    assertTrue(selection.startsWith("relative_path"))
}

28과 29를 둘 다 확인하는 게 요점입니다. 한쪽만 두면 경계가 어디인지를 검증하지 못합니다.

덧 — 테스트는 이걸 왜 못 잡았나

같은 앱의 계측 테스트가 갤러리 화면을 열고 있었는데도 이 크래시를 못 잡았습니다. 나중에 보니 그 테스트 자신이 minSdk에서 안 돌고 있었습니다. 권한 @Rule에 API 33+ 전용 권한 이름을 못박아 둬서, 그 미만 기기에서는 본문이 시작조차 못 하고 있었습니다.

같은 계열의 다른 사고라 따로 정리해 두었습니다(계측 테스트가 minSdk에서 안 돌고 있었다 편). 이 글이 "앱이 왜 죽었나"라면, 그쪽은 "검증이 왜 초록불이었나"입니다.

정리

  • RELATIVE_PATHAPI 29부터입니다. 문자열 상수라 컴파일·Lint가 안 잡아 줍니다.
  • 조회는 죽고, 저장은 조용히 어긋납니다. 둘 다 갈라야 합니다.
  • 실사용자 분포가 크래시를 가려 줍니다. 지표 0건은 안전의 근거가 아닙니다.
  • SDK 분기는 인자 받는 함수로 뽑아 경계 양쪽을 테스트로 고정하세요.

자주 묻는 것

minSdk를 29로 올려 버리면 안 되나요?

가능한 선택이고, 그 편이 코드는 훨씬 단순해집니다. 다만 그건 버리는 사용자층을 알고 내리는 결정이어야 합니다. 26으로 두고 구현을 안 하는 것이 문제였지, 26 자체가 문제는 아닙니다.

Android 9 에뮬레이터에서도 사진이 안 보이는데요?

저장 쪽 분기를 안 고쳤다면 사진이 다른 폴더에 떨어져 있습니다. 조회를 고쳐도 없는 곳을 찾는 셈이라 계속 비어 보입니다. 저장 분기의 mkdirs()까지 확인하세요.

DATA를 쓰면 정책 위반 아닌가요?

deprecated는 "쓰지 말라"가 아니라 "새 코드에서는 다른 것을 쓰라"입니다. API 28 이하 분기 안에서만 쓰는 것이라 Scoped Storage 정책과 충돌하지 않습니다. 29 이상에서 쓰지 않도록 분기를 확실히 갈라 두는 게 중요합니다.

728x90
반응형
LIST
728x90
반응형
SMALL

위젯을 붙인 앱에서 Crashlytics에 이런 것이 올라옵니다.

FATAL EXCEPTION: main
java.lang.RuntimeException: Unable to start activity
    ComponentInfo{...androidx.glance.appwidget.action.InvisibleActionTrampolineActivity}
Caused by: java.lang.IllegalArgumentException:
    List adapter activity trampoline invoked without specifying target intent.
    at androidx.glance.appwidget.action.ActionTrampolineKt
        .launchTrampolineAction(ActionTrampoline.kt:93)

내 폰에서 위젯을 아무리 눌러도 재현되지 않습니다. 에뮬레이터에서도 멀쩡합니다. 그런데 사용자에게는 계속 납니다. 스택 트레이스에 내가 쓴 코드가 한 줄도 없어서 어디서부터 봐야 할지도 막막합니다.

제 앱에서는 이걸 두 번 겪었습니다. 한 번은 원인을 찾아 막았는데, 8일 뒤 같은 예외가 그대로 다시 났습니다. 막아야 할 컴포넌트가 하나가 아니었기 때문입니다.

이 액티비티는 내가 만든 게 아니다

InvisibleActionTrampolineActivityandroidx.glance:glance-appwidget자기 매니페스트에 선언해 넣는 컴포넌트입니다. 매니페스트 병합 결과물에는 들어 있지만 내 프로젝트 소스 어디를 뒤져도 나오지 않습니다.

하는 일은 이름 그대로 중계(트램폴린)입니다. 시작할 때 받은 인텐트에서 목적지 인텐트를 꺼내 그쪽으로 넘기고 자신은 사라집니다. 문제는 그 목적지 정보가 없을 때입니다. 그러면 위 IllegalArgumentException을 던지고, 액티비티 시작 중 발생한 예외라 앱이 그대로 죽습니다.

왜 재현이 안 되나
이 액티비티는 정상적인 위젯 클릭 경로에서는 아예 안 거칩니다(뒤에서 근거를 봅니다). 그래서 개발자가 위젯을 눌러 보는 것으로는 절대 나오지 않습니다. 재현되지 않는다고 넘어가면 사용자 쪽에서만 계속 쌓입니다.

핵심 — 트램폴린은 두 개다

여기가 제가 8일을 날린 지점입니다. glance-appwidget 1.1.1은 이름이 비슷한 트램폴린 액티비티를 두 개 넣습니다.

컴포넌트비고
InvisibleActionTrampolineActivity크래시 로그에 이 이름이 찍혀서 먼저 찾게 되는 쪽
ActionTrampolineActivity형제. 같은 예외를 같은 ActionTrampoline.kt:93에서 던진다

저는 처음에 로그에 찍힌 Invisible 쪽만 끄고 배포했습니다. 그리고 8일 뒤, 형제 쪽으로 FATAL 5건이 사용자 4명에게 났습니다. Crashlytics에서는 예외 메시지도 발생 줄 번호도 같아서, 처음에는 수정이 반영이 안 된 줄 알았습니다. 컴포넌트 이름을 대조하고서야 절반만 막혀 있었다는 걸 알았습니다.

해결 — 매니페스트에서 둘 다 끈다

<application> 안에 두 블록을 나란히 넣습니다.

<activity
    android:name="androidx.glance.appwidget.action.InvisibleActionTrampolineActivity"
    android:enabled="false"
    tools:node="merge"
    tools:replace="android:enabled" />

<activity
    android:name="androidx.glance.appwidget.action.ActionTrampolineActivity"
    android:enabled="false"
    tools:node="merge"
    tools:replace="android:enabled" />

<manifest> 태그에 tools 네임스페이스가 없으면 같이 넣어야 합니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

속성 두 개가 왜 필요한가

속성없으면
tools:node="merge"내 선언이 라이브러리 선언을 통째로 대체합니다. 라이브러리가 지정해 둔 launchMode, taskAffinity, theme 같은 속성이 전부 날아갑니다. merge여야 기존 선언 위에 enabled만 얹힙니다.
tools:replace="android:enabled"라이브러리가 enabled를 명시하고 있어 병합 충돌로 빌드가 실패합니다. 내 값을 이기게 하겠다는 선언이 필요합니다.
두 블록은 한 몸으로 다루세요. 나중에 매니페스트를 정리하다 하나만 지우면 제가 겪은 상태로 정확히 되돌아갑니다. 저는 두 블록 사이에 "형제 트램폴린. 따로 손대지 말 것"이라는 주석을 넣어 뒀습니다.

꺼도 되는 근거 — 정상 클릭은 이 경로를 안 탄다

남의 라이브러리 컴포넌트를 끄는 것이라, 무엇을 잃는지 확인하고 넣어야 합니다. glance-appwidget 1.1.1 기준으로 확인한 내용입니다.

  • 트램폴린 인텐트를 심는 applyTrampolineIntentgetFillInIntentForAction 안에서만 호출됩니다. 이 함수는 Lazy 컬렉션 경로(LazyColumn 등의 항목 클릭)에서 쓰입니다.
  • actionRunCallback브로드캐스트로 갑니다.
  • actionStartActivityPendingIntent.getActivity로 갑니다.

위젯에 Lazy 컬렉션이 없다면 일반 버튼과 클릭은 이 액티비티를 거치지 않고, 꺼도 동작이 달라지지 않습니다. 제 위젯 세 개가 그 조건이라 껐고, 끈 뒤 위젯 클릭은 그대로 동작합니다.

반대로 이럴 때는 빼야 합니다
위젯에 LazyColumn·LazyVerticalGrid를 넣는 순간, 그 안의 항목 클릭이 조용히 안 먹습니다. 크래시가 아니라 "눌러도 아무 일이 없는" 형태라 알아채기 어렵습니다. 위젯에 목록을 넣게 되면 이 블록을 걷어내고 다른 방법을 찾아야 합니다.

막혔는지 확인하는 법

배포 전에 직접 찔러 볼 수 있습니다. 다만 일반 셸에서는 확인이 안 됩니다. 이 액티비티들은 exported="false"라, 셸 사용자(uid 2000)로 실행하면 컴포넌트가 켜져 있든 꺼져 있든 SecurityException이 먼저 뜹니다.

그래서 루팅된 에뮬레이터에서 확인합니다.

adb root
adb shell am start -n com.example.myapp/androidx.glance.appwidget.action.InvisibleActionTrampolineActivity
상태결과
막기 전앱이 죽습니다. 로그캣에 위와 똑같은 IllegalArgumentException이 찍힙니다
막은 뒤Error type 3 — 컴포넌트를 시작할 수 없음. 앱은 멀쩡합니다

저는 이 방법으로 수정 전 상태에서 크래시를 한 건 재현한 뒤 블록을 넣었습니다. 재현을 못 해 본 채로 "이걸 끄면 아마 될 것"으로 배포하는 것과는 확신의 크기가 다릅니다. 컴포넌트 이름만 바꿔서 형제 쪽도 같이 확인하세요.

정직하게 남겨 둘 것 — 무엇이 이걸 시작하는지는 모른다

글을 이렇게 끝내고 싶지만, 확인 못 한 부분을 확인한 것처럼 쓰면 다음에 이걸 읽는 사람이 저처럼 헛다리를 짚습니다.

어떤 주체가 이 트램폴린을 목적지 인텐트 없이 시작하는지는 끝내 특정하지 못했습니다. 저는 스토어 업로드마다 도는 자동 검사가 am start로 컴포넌트를 훑는 것이라 짐작했는데, 실기기에서 재 보니 셸은 exported="false"에 막혀 SecurityException을 받습니다. 그 경로가 아니라는 것만 확인된 셈입니다.

그래도 막는 이유는, 정상 동작에서는 어차피 안 불리는 경로라 끄는 쪽의 손해가 없기 때문입니다. 원인 경로를 안다고 가정하고 하나만 껐던 것이 애초의 실수였습니다.

정리

  • 스택에 내 코드가 없는 크래시라도 매니페스트 병합 결과물에는 범인이 있을 수 있습니다.
  • Glance를 쓰면 InvisibleActionTrampolineActivityActionTrampolineActivity 둘 다 끕니다.
  • 끌 때는 tools:node="merge" + tools:replace="android:enabled"를 같이 씁니다.
  • 위젯에 Lazy 컬렉션이 없을 때만 안전합니다. 넣게 되면 그때 걷어내세요.

자주 묻는 것

Crashlytics에 안 잡히는데 괜찮은 건가요?

위젯을 쓰는 앱이고 Glance 버전이 같다면 소지는 있습니다. 저는 한 앱에서 사용자 2명이 겪은 걸 보고 같은 구조의 다른 앱에도 선제로 넣었습니다. 지표가 깨끗한 것과 코드가 맞는 것은 다릅니다.

Glance를 올리면 해결되나요?

제가 확인한 건 1.1.1 기준입니다. 상위 버전에서 동작이 바뀌었는지는 검증하지 않았으니, 올린다면 위 adb root 확인을 다시 해 보고 판단하세요.

액티비티를 지우면(tools:node="remove") 안 되나요?

enabled="false"는 선언은 남기고 시작만 막는 쪽이라 라이브러리 다른 코드가 이 컴포넌트를 참조해도 병합이 깨지지 않습니다. 저는 remove는 시도하지 않았습니다.

728x90
반응형
LIST
728x90
반응형
SMALL

윈도우에서 클로드 코드(Claude Code)를 쓰다 보면 한글이 세 군데에서 각각 다르게 깨집니다.

// ① 터미널 출력
?????? ??? ???

// ② 파이썬 실행
UnicodeEncodeError: 'cp949' codec can't encode character '✓'

// ③ 만들어 준 PowerShell 스크립트 실행
+ Write-Output "?��?��"
              ~~~~~~
문자열에 종료 기호가 없습니다.

세 증상 모두 AI 도구의 버그가 아닙니다. 윈도우의 기본 문자 인코딩이 UTF-8이 아니라 CP949(euc-kr 계열)이기 때문에 생기는 오래된 문제이고, 도구가 한글을 많이 다루게 되면서 한꺼번에 드러난 것입니다.

깨지는 지점이 셋이므로, 고치는 곳도 셋입니다. 하나만 고치고 "왜 아직도 깨지지"를 반복하는 경우가 대부분입니다.

증상깨지는 지점고칠 곳
화면에 ?나 네모터미널 출력 인코딩콘솔 코드페이지
UnicodeEncodeError: 'cp949'프로그램의 표준 출력환경 변수 · 스크립트 상단
스크립트가 실행조차 안 됨파일 저장 인코딩UTF-8 BOM으로 저장

① 터미널 출력이 ?로 나올 때

콘솔의 코드페이지를 UTF-8(65001)로 바꿉니다. 임시로는 이 한 줄이면 됩니다.

chcp 65001

PowerShell이라면 출력·입력 인코딩까지 함께 지정하는 편이 확실합니다.

# PowerShell 프로필에 넣어두면 매번 적용된다
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding  = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8

프로필 파일 위치는 이렇게 확인하고, 없으면 만들면 됩니다.

echo $PROFILE
# 없으면
New-Item -ItemType File -Path $PROFILE -Force

근본 해결 — 시스템 로캘을 UTF-8로

매번 설정하기 싫다면 윈도우 전체를 UTF-8로 돌릴 수 있습니다.

설정 → 시간 및 언어 → 언어 및 지역 → 시스템 로캘 변경 → "세계 언어 지원을 위한 Beta: 유니코드 UTF-8 사용" 체크 → 재부팅

이름 그대로 베타 옵션입니다. 개발 환경은 대부분 편해지지만, CP949를 전제로 만들어진 오래된 국산 프로그램에서 글자가 깨질 수 있습니다. 업무용 PC라면 되돌릴 수 있다는 점을 알고 켜세요. 문제가 생기면 체크를 해제하고 재부팅하면 원래대로 돌아옵니다.

UnicodeEncodeError: 'cp949' — 파이썬

파이썬이 print()로 한글이나 특수문자(, , 이모지)를 출력할 때 터집니다. 스크립트 안의 한글은 멀쩡한데 출력하는 순간 죽는 것이 특징입니다.

방법 A — 환경 변수 (가장 간단)

# 현재 세션만
$env:PYTHONIOENCODING = "utf-8"

# 영구 적용
[Environment]::SetEnvironmentVariable(
    "PYTHONIOENCODING", "utf-8", "User")

방법 B — 스크립트 자체가 스스로 해결하게 (권장)

다른 PC나 자동화(작업 스케줄러)에서도 돌아가야 한다면, 환경 변수에 기대지 말고 스크립트 상단에서 표준 출력을 UTF-8로 다시 감싸는 편이 안전합니다.

# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
자동화 스크립트에는 이 두 줄을 기본 템플릿으로 넣어두는 편이 좋습니다. 손으로 실행할 때는 멀쩡하다가 작업 스케줄러로 돌릴 때만 죽는 사고가 이것 때문에 자주 납니다. 실행 주체가 다르면 환경 변수도 다르기 때문입니다.

파일 읽기·쓰기도 마찬가지

# ❌ 윈도우에서는 기본이 cp949 라 깨진다
open("data.txt").read()

# ✅ 항상 명시
open("data.txt", encoding="utf-8").read()
open("out.txt", "w", encoding="utf-8").write(text)

③ 스크립트가 실행조차 안 될 때 — BOM 문제

여기가 가장 안 알려져 있고, 가장 오래 헤매는 지점입니다. AI가 만들어 준 .ps1 파일을 실행했더니 문법 오류가 나고, 열어보면 코드는 멀쩡합니다.

Write-Output "발행 완료"
# → 문자열에 종료 기호가 없습니다.

Windows PowerShell 5.1은 BOM이 없는 파일을 CP949로 읽습니다. UTF-8로 저장된 한글이 CP949로 해석되면서 따옴표 짝이 깨진 것처럼 보이는 것입니다. 코드가 아니라 파일이 저장된 방식이 문제입니다.

해결은 하나뿐입니다. .ps1 파일은 UTF-8 BOM 포함으로 저장할 것. "UTF-8"과 "UTF-8 with BOM"은 다른 선택지이고, PowerShell 5.1에서는 후자여야 합니다.
# BOM 포함으로 다시 저장
$content = Get-Content .\script.ps1 -Raw -Encoding UTF8
[System.IO.File]::WriteAllText(
    "$PWD\script.ps1", $content,
    [System.Text.UTF8Encoding]::new($true))   # $true = BOM 포함

에디터에서라면 VS Code 오른쪽 아래 인코딩 표시를 눌러 Save with Encoding → UTF-8 with BOM을 고르면 됩니다.

Set-Content로 파일을 만들 때

# ❌ PowerShell 5.1의 Set-Content 기본값은 시스템 ANSI(CP949)
Set-Content out.txt $text

# ✅ 명시
Set-Content out.txt $text -Encoding utf8

AI 도구에 아예 규칙으로 박아두기

매번 "한글 안 깨지게 저장해 줘"라고 말하는 대신, 프로젝트 규칙 파일(CLAUDE.md 등)에 적어두면 계속 적용됩니다. 실제로 효과가 큰 부분입니다.

# 인코딩 규칙 (윈도우 환경)

- 모든 텍스트 파일은 UTF-8 로 저장한다.
- .ps1 파일은 반드시 UTF-8 **BOM 포함** 으로 저장한다.
  (BOM 이 없으면 PowerShell 5.1 이 CP949 로 읽어 파싱이 깨진다)
- 파이썬 스크립트는 상단에 표준 출력 UTF-8 래핑을 넣는다.
- open() 과 Set-Content 에는 인코딩을 항상 명시한다.

터미널에서 한글 입력이 이상할 때

출력이 아니라 입력 쪽 증상도 있습니다. 한글을 치는 도중 글자가 커서와 어긋나게 그려지거나, 문장 중간을 수정하면 조합이 흐트러지는 현상입니다.

이것은 코드페이지가 아니라 터미널의 IME 처리 문제라 설정으로 해결되지 않습니다. 대응은 두 가지입니다.

  • 도구를 최신 버전으로 업데이트 — 한국어·중국어·일본어 조합 입력 처리는 개선이 이어지는 영역입니다.
  • 긴 한글 프롬프트는 다른 곳에서 작성해 붙여넣기 — 조합 중 렌더링 문제라, 완성된 문자열을 붙여넣으면 영향을 받지 않습니다.
Windows Terminal을 쓰고 있다면 그것부터 최신으로 올리세요. 셸(PowerShell)과 터미널 앱은 별개이고, IME 관련 문제는 터미널 앱 쪽에서 개선되는 경우가 많습니다.

점검 순서

확인내용
1chcp를 쳐서 949인지 65001인지 본다
2깨지는 것이 화면 출력인지 저장된 파일인지 가른다 (파일을 다른 에디터로 열어 확인)
3파이썬이면 PYTHONIOENCODING 또는 스크립트 상단 래핑
4.ps1이 실행 안 되면 BOM 여부부터 (내용이 아니라 저장 방식)
5자동화로 돌릴 때만 깨진다면 실행 계정의 환경 변수를 의심
6git에서 한글 파일명이 \355\225\234처럼 보이면 git config --global core.quotepath false

자주 묻는 질문

Q. chcp 65001을 했는데도 깨집니다.

깨지는 지점이 출력이 아니라 파일 저장일 가능성이 큽니다. 그 파일을 VS Code로 열어 오른쪽 아래 인코딩 표시를 확인하세요. CP949로 저장돼 있다면 코드페이지를 아무리 바꿔도 그대로입니다.

Q. 손으로 실행하면 되는데 작업 스케줄러에서만 깨집니다.

실행 계정이 달라 환경 변수가 적용되지 않은 것입니다. 환경 변수 대신 스크립트 자체가 인코딩을 지정하도록 바꾸면 실행 주체와 무관해집니다.

Q. UTF-8 BOM을 넣으면 다른 데서 문제 되지 않나요?

.ps1은 BOM이 있어야 안전하고, 반대로 셸 스크립트(.sh)·JSON·YAML은 BOM이 있으면 안 됩니다. 파일 종류에 따라 다르므로 일괄로 BOM을 붙이지 마세요.

Q. PowerShell 7을 쓰면 해결되나요?

많이 나아집니다. 기본 인코딩이 UTF-8이라 BOM 문제에서 자유롭습니다. 다만 윈도우에 기본 설치된 5.1도 함께 남아 있으므로, 스크립트가 어느 쪽으로 실행되는지는 여전히 확인해야 합니다.

Q. AI가 만든 코드의 한글 주석만 깨집니다.

파일 저장 인코딩 문제입니다. 소스 파일을 UTF-8로 저장하고, 자바 계열이라면 컴파일 옵션에 -encoding UTF-8이 들어가 있는지도 확인하세요.

정리

윈도우에서 한글이 깨질 때 가장 먼저 할 일은 고치는 게 아니라 어디서 깨졌는지 가르는 것입니다. 화면에서 깨진 것인지, 파일에 잘못 저장된 것인지, 파일은 멀쩡한데 읽는 쪽이 잘못 해석한 것인지에 따라 답이 완전히 다릅니다.

그리고 한 번 고쳤으면 규칙으로 남기세요. 인코딩 문제는 잊을 만하면 다른 얼굴로 돌아오고, 그때마다 같은 시간을 다시 씁니다.

728x90
반응형
LIST
728x90
반응형
SMALL

앱 안 웹뷰에서 "구글로 로그인"을 눌렀더니 로그인 화면 대신 이 문구가 뜹니다.

403. That's an error.

Error: disallowed_useragent

You can't sign in from this screen because
this app doesn't comply with Google's secure browsers policy.

모바일 크롬에서 같은 페이지를 열면 잘 됩니다. 앱 안에서만 막힙니다. 카카오·네이버 로그인은 되는데 구글만 안 되는 것도 흔한 조합입니다.

원인 — 구글이 내장 웹뷰의 로그인을 정책으로 막았다

버그가 아니라 의도된 차단입니다. 구글은 2021년 9월 말부터 앱에 내장된 웹뷰(embedded WebView)에서의 OAuth 인증을 허용하지 않습니다. 이유는 보안입니다.

웹뷰는 앱이 그 안을 들여다볼 수 있습니다. 자바스크립트를 주입해 입력값을 읽거나, 화면을 캡처하거나, 쿠키를 꺼낼 수 있습니다. 즉 악의적인 앱이 진짜 구글 로그인 화면을 띄워놓고 아이디와 비밀번호를 그대로 가져갈 수 있다는 뜻입니다. 사용자는 주소창이 없어서 진짜인지 확인할 방법도 없습니다.

내장 WebViewCustom Tabs · 외부 브라우저
앱이 입력값을 읽을 수 있나가능불가
주소창(도메인 확인)없음있음
브라우저에 저장된 로그인 세션공유 안 됨공유됨
구글 OAuth차단허용

먼저 — 하면 안 되는 우회 두 가지

1) User-Agent 문자열을 바꿔 크롬인 척하기. 검색하면 제일 많이 나오는 방법이고, 실제로 한동안 통과되기도 합니다. 하지만 정책 위반이고 탐지 방식이 바뀔 때마다 다시 막힙니다. 출시한 앱이 어느 날 갑자기 로그인 불가가 되는 것보다 나쁜 상황은 별로 없습니다.

2) 사용자에게 "크롬을 기본 브라우저로 설정하세요"라고 안내하기. 이것은 사용자 쪽 임시 조치이지 앱의 해결책이 아닙니다. 앱을 쓰는 사람 전원에게 설정을 바꾸라고 요구할 수는 없습니다.

정답은 하나입니다. 로그인 구간만 웹뷰 밖으로 꺼내는 것입니다.

해결 — 로그인만 Custom Tabs로 연다

Chrome Custom Tabs는 앱 안에 떠 있는 진짜 브라우저입니다. 앱은 그 안을 볼 수 없고, 사용자는 주소창으로 도메인을 확인할 수 있으며, 크롬에 로그인돼 있으면 계정 선택만으로 끝납니다. 구글이 요구하는 조건을 모두 만족합니다.

1단계 — 의존성

// build.gradle.kts
implementation("androidx.browser:browser:1.8.0")

2단계 — 로그인 URL만 가로채서 Custom Tabs로 넘긴다

웹뷰는 그대로 두고, 구글 인증 주소로 이동하려는 순간에만 끼어들면 됩니다.

webView.webViewClient = object : WebViewClient() {

    override fun shouldOverrideUrlLoading(
        view: WebView, request: WebResourceRequest
    ): Boolean {
        val url = request.url.toString()

        if (isOAuthUrl(url)) {
            openInCustomTab(view.context, url)
            return true          // 웹뷰는 열지 않는다
        }
        return false
    }

    private fun isOAuthUrl(url: String): Boolean =
        url.startsWith("https://accounts.google.com/") ||
        url.startsWith("https://appleid.apple.com/auth/authorize")
}

private fun openInCustomTab(context: Context, url: String) {
    CustomTabsIntent.Builder()
        .setShowTitle(true)
        .build()
        .launchUrl(context, Uri.parse(url))
}
가로챌 주소는 구글 인증 도메인만으로 좁히세요. 웹뷰 안에서 열려야 정상인 페이지까지 브라우저로 튀어나가면 사용자가 앱과 브라우저 사이를 오가게 됩니다.

3단계 — 로그인 끝나고 앱으로 돌아오게 만든다

여기서 대부분 막힙니다. Custom Tabs로 로그인은 됐는데 브라우저에 머물러 있고 앱으로 안 돌아옵니다. 인증이 끝난 뒤 서버가 보내는 리다이렉트 주소를 앱이 받을 수 있는 주소로 만들어야 합니다.

<!-- AndroidManifest.xml -->
<activity
    android:name=".AuthCallbackActivity"
    android:exported="true"
    android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- 서버가 최종적으로 리다이렉트할 주소 -->
        <data android:scheme="myapp" android:host="auth" />
    </intent-filter>
</activity>
class AuthCallbackActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // myapp://auth?token=... 형태로 돌아온다
        val token = intent?.data?.getQueryParameter("token")

        if (token != null) {
            // 세션 저장 후 원래 화면으로 복귀
            SessionStore.save(token)
            startActivity(Intent(this, MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
            })
        }
        finish()
    }
}
구글 OAuth의 리다이렉트 URI에 myapp://를 직접 넣을 수는 없습니다. 웹 클라이언트는 https 주소만 받습니다. 그래서 흐름은 구글 → 우리 서버(https 콜백) → 앱 스킴 두 단계가 됩니다. 서버가 인증을 마친 뒤 myapp://auth?token=...으로 한 번 더 보내주는 구조입니다.

4단계 — 돌아온 세션을 웹뷰가 알게 한다

Custom Tabs와 웹뷰는 쿠키 저장소가 다릅니다. 브라우저에서 로그인했다고 웹뷰가 로그인 상태가 되지는 않습니다. 그래서 돌려받은 토큰을 웹뷰 쪽에 심어줘야 합니다.

// 토큰을 쿼리로 넘겨 서버가 웹뷰 세션을 만들게 하거나
webView.loadUrl("https://example.com/app/enter?token=$token")

// 서버가 쿠키 기반이라면 직접 주입
CookieManager.getInstance().apply {
    setAcceptCookie(true)
    setCookie("https://example.com", "session=$token; Path=/; Secure; HttpOnly")
    flush()          // ★ 빼먹으면 앱 재시작 시 로그인이 풀린다
}

다른 선택지 — 아예 네이티브 로그인으로

웹뷰 껍데기 앱이 아니라 네이티브 화면이 있는 앱이라면, 웹 OAuth 대신 안드로이드 네이티브 로그인을 쓰는 편이 사용자 경험이 훨씬 낫습니다. 계정 선택 시트가 바로 뜨고 브라우저를 거치지 않습니다.

방식적합한 경우주의
Custom Tabs + 웹 OAuth웹 화면이 주인공인 하이브리드 앱세션을 웹뷰로 넘기는 처리가 필요
네이티브 구글 로그인네이티브 화면 중심 앱SHA-1 지문 등록 필수. Play 앱 서명을 쓰면 업로드 키가 아니라 앱 서명 키의 지문이어야 한다
네이티브 방식에서 가장 많이 막히는 지점은 로그인 실패 코드 10(개발자 오류)입니다. 원인은 대부분 지문 등록 누락이거나 업로드 키 지문만 넣은 것입니다.

점검 순서

확인내용
1User-Agent를 조작하는 코드가 남아 있지 않은가 — 있으면 먼저 제거
2가로채는 URL 조건이 인증 도메인만으로 좁혀져 있는가
3OAuth 콘솔에 등록한 리다이렉트 URI와 서버가 실제로 보내는 주소가 한 글자까지 같은가
4앱 스킴 intent-filterBROWSABLE 카테고리가 있는가 (없으면 브라우저가 앱을 못 부른다)
5콜백 액티비티가 singleTask인가 — 아니면 화면이 중복으로 쌓인다
6쿠키 주입 후 flush()를 호출했는가
7크롬이 없는 기기에서도 동작하는가 (Custom Tabs 미지원 시 일반 브라우저로 폴백)

자주 묻는 질문

Q. User-Agent를 바꿨더니 되던데요?

지금은 통과할 수 있습니다. 문제는 그것이 정책 위반이라 언제든 다시 막힌다는 점입니다. 그때는 이미 출시된 앱의 로그인이 통째로 죽고, 스토어 업데이트가 반영될 때까지 사용자는 들어오지 못합니다. 실제로 이 방식으로 버티다 한 번에 무너지는 사례가 반복돼 왔습니다.

Q. 카카오·네이버 로그인은 웹뷰에서 잘 되는데요?

각 서비스의 정책이 다릅니다. 다만 국내 서비스들도 자체 SDK나 앱 전환 방식을 권장하는 방향으로 가고 있습니다. 구글만 예외적으로 엄격한 것이지, 웹뷰 안에서 남의 계정 로그인을 처리하는 구조 자체가 권장되지 않습니다.

Q. Custom Tabs를 열었더니 브라우저 앱으로 완전히 튀어나갑니다.

CustomTabsIntent가 아니라 일반 ACTION_VIEW 인텐트로 열고 있을 가능성이 큽니다. 또는 기기에 Custom Tabs를 지원하는 브라우저가 없어 폴백된 경우입니다. 후자는 정상 동작이며, 앱 복귀는 앱 스킴 콜백이 처리합니다.

Q. 로그인은 되는데 앱으로 안 돌아옵니다.

3단계의 앱 스킴 설정 문제입니다. BROWSABLE 카테고리 누락, 스킴 오타, 서버가 보내는 최종 리다이렉트 주소 불일치 중 하나입니다. 브라우저 주소창에 myapp://auth를 직접 쳐서 앱이 뜨는지부터 확인하면 빠릅니다.

Q. 앱을 껐다 켜면 로그인이 풀립니다.

쿠키를 주입한 뒤 CookieManager.flush()를 호출하지 않아서입니다. 웹뷰 쿠키는 메모리에 있다가 flush 시점에 디스크로 내려갑니다.

정리

disallowed_useragent는 고칠 수 있는 오류가 아니라 지켜야 할 규칙입니다. 앱이 들여다볼 수 있는 화면에서는 남의 계정 로그인을 받지 말라는 것이고, 그 요구는 앞으로 더 강해질 방향이지 느슨해질 방향이 아닙니다.

따라서 로그인 구간만 Custom Tabs로 꺼내고, 인증이 끝나면 앱 스킴으로 돌아와 세션을 웹뷰에 심는 것이 정공법입니다. 작업량은 반나절이면 충분하고, User-Agent를 바꾸며 버티는 방식보다 훨씬 오래갑니다.

728x90
반응형
LIST
728x90
반응형
SMALL

앱 안 웹뷰가 흰 화면이거나 회색 오류 페이지를 띄우고, 로그캣에 이렇게 찍힙니다.

net::ERR_CACHE_MISS

같은 주소를 모바일 크롬으로 열면 멀쩡합니다. 검색하면 "인터넷 권한을 추가하세요"라는 답이 가장 먼저 나오는데, 권한이 이미 있는데도 나는 경우가 절반은 됩니다. 이름 그대로 "캐시에서 못 찾았다"는 뜻이라 원인이 세 갈래로 갈리기 때문입니다.

먼저 세 갈래 중 어디인지 가른다

증상유력한 원인
앱의 모든 페이지가 처음부터 안 열린다① INTERNET 권한 누락
비행기 모드였다가 켰을 때, 또는 특정 화면만 계속 실패② 캐시 모드 설정
로그인·검색 버튼을 누른 다음, 또는 뒤로가기 했을 때만③ POST 요청의 히스토리 이동

이 구분을 먼저 하지 않고 코드를 고치면, 남의 답을 그대로 붙여넣어 놓고 "왜 안 되지"를 반복하게 됩니다.

원인 ① INTERNET 권한 — 가장 흔하고 가장 허무하다

웹뷰는 네트워크를 못 쓰면 캐시에서 찾으려 하고, 캐시도 없으니 ERR_CACHE_MISS를 냅니다. "인터넷이 없다"가 아니라 "캐시에 없다"로 나오는 탓에 원인이 잘 안 보입니다.

<!-- AndroidManifest.xml — <application> 바깥, 최상위에 -->
<uses-permission android:name="android.permission.INTERNET" />
위치를 자주 틀립니다. <uses-permission><manifest> 바로 아래에 와야 합니다. <application> 안에 넣으면 빌드는 통과하는데 권한은 안 붙습니다. 그리고 라이브러리 모듈이 아니라 app 모듈의 매니페스트인지도 확인하세요.

원인 ② 캐시 모드 — LOAD_CACHE_ONLY가 범인

웹뷰의 캐시 정책은 네 가지입니다. 이 중 하나를 잘못 고르면 네트워크가 멀쩡해도 오류가 납니다.

모드동작ERR_CACHE_MISS 위험
LOAD_DEFAULT캐시 유효하면 캐시, 아니면 네트워크낮음 (기본값)
LOAD_CACHE_ELSE_NETWORK만료돼도 캐시 우선, 없으면 네트워크낮음
LOAD_NO_CACHE항상 네트워크없음 (대신 매번 통신)
LOAD_CACHE_ONLY네트워크를 아예 안 씀높음 ★

오프라인 대응을 넣다가 LOAD_CACHE_ONLY로 고정해 둔 코드가 대표적인 사고 지점입니다. 캐시가 비어 있는 첫 실행에서 100% 실패합니다.

webView.settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true
    cacheMode = WebSettings.LOAD_DEFAULT   // ★ 특별한 이유 없으면 이것
}

네트워크 상태에 따라 갈라야 한다면, 고정하지 말고 그때그때 판단해야 합니다.

private fun currentCacheMode(context: Context): Int {
    val cm = context.getSystemService(ConnectivityManager::class.java)
    val caps = cm.getNetworkCapabilities(cm.activeNetwork)
    val online = caps?.hasCapability(
        NetworkCapabilities.NET_CAPABILITY_INTERNET) == true

    return if (online) WebSettings.LOAD_DEFAULT
           else WebSettings.LOAD_CACHE_ELSE_NETWORK
}

// 로드 직전에 적용
webView.settings.cacheMode = currentCacheMode(this)
webView.loadUrl(url)
오프라인일 때도 LOAD_CACHE_ONLY보다 LOAD_CACHE_ELSE_NETWORK가 낫습니다. 캐시가 있으면 캐시를 쓰고, 없으면 네트워크를 시도해 진짜 네트워크 오류를 보여주기 때문에 사용자에게 안내할 메시지가 정확해집니다.

같이 볼 것 — 네트워크 로드 차단 플래그

setBlockNetworkLoads(true)를 켜 두면 캐시 모드와 무관하게 통신이 막혀 같은 오류가 납니다. 오프라인 진입 시 켜고 복귀할 때 끄는 것을 빼먹은 코드가 흔합니다.

webView.settings.blockNetworkLoads = false   // 온라인 복귀 시 반드시 해제
// blockNetworkImage 도 같은 방식으로 관리

원인 ③ POST 뒤로가기 — 캐시가 없는 게 정상인 경우

로그인·검색·주문처럼 POST로 이동한 페이지는 웹뷰가 캐시에 담지 않습니다. 그래서 뒤로가기나 새로고침으로 그 페이지를 다시 그리려 하면 가져올 캐시가 없어서 ERR_CACHE_MISS가 납니다. 이 경우는 설정 실수가 아니라 구조의 문제입니다.

세 가지 대응이 있고, 상황에 따라 고르면 됩니다.

대응 A — 서버가 POST 뒤에 리다이렉트하게 한다 (권장)

POST 처리 후 결과 페이지로 302 리다이렉트하면(PRG 패턴), 히스토리에 남는 것은 GET이라 뒤로가기가 깨지지 않습니다. 웹 쪽을 고칠 수 있다면 이게 가장 깔끔합니다.

대응 B — 뒤로가기를 앱이 직접 처리한다

// 히스토리 항목이 POST였다면 뒤로가기 대신 새로 로드
webView.setOnKeyListener { _, keyCode, event ->
    if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
        if (webView.canGoBack()) {
            webView.goBack()
        } else {
            finish()
        }
        true
    } else false
}

그래도 깨지는 화면이 있다면, 해당 URL만 기억해 두었다가 GET으로 다시 로드하는 편이 확실합니다.

대응 C — 오류를 잡아 한 번 재시도

사용자에게 회색 오류 페이지를 보여주지 않기 위한 안전망입니다.

webView.webViewClient = object : WebViewClient() {

    private var retried = false

    override fun onReceivedError(
        view: WebView, request: WebResourceRequest, error: WebResourceError
    ) {
        // 메인 프레임의 캐시 미스만 재시도 (이미지·광고 실패는 무시)
        if (!request.isForMainFrame) return
        if (error.errorCode != ERROR_UNKNOWN &&
            error.description?.contains("CACHE_MISS") != true) return

        if (!retried) {
            retried = true
            view.settings.cacheMode = WebSettings.LOAD_NO_CACHE
            view.loadUrl(request.url.toString())
        }
    }
}
onReceivedError이미지·스크립트 같은 하위 리소스 실패에도 호출됩니다. isForMainFrame으로 거르지 않으면 배너 하나 실패에 페이지 전체를 다시 로드하게 됩니다. 그리고 재시도는 반드시 한 번만 — 플래그가 없으면 무한 루프에 빠집니다.

"매번 LOAD_NO_CACHE로 하면 되지 않나요"

증상은 사라집니다. 대신 모든 요청이 네트워크로 나갑니다. 이미지와 CSS까지 매번 다시 받으므로 화면 전환이 눈에 띄게 느려지고 데이터도 더 씁니다. 원인 ①·③이 진짜 이유였다면, 문제를 덮은 채 성능만 잃는 선택입니다.

임시 확인용으로는 유용합니다. LOAD_NO_CACHE로 바꿨을 때 바로 해결되면 원인 ②, 그래도 안 되면 원인 ①일 가능성이 큽니다. 진단 도구로 쓰고 되돌리세요.

Flutter · React Native일 때

환경확인 지점
Flutter (webview_flutter)안드로이드 매니페스트의 INTERNET 권한. 플러그인이 자동으로 넣어주지 않는 구성이 있어, 릴리스 빌드에서만 터지는 사례가 나온다
React Native (react-native-webview)cacheEnabled, cacheMode prop. LOAD_CACHE_ONLY에 해당하는 값을 넘기고 있지 않은지
공통디버그에서는 권한이 자동 병합돼 되고, 릴리스에서만 실패하는 패턴이 있으니 릴리스 APK의 병합된 매니페스트를 직접 열어볼 것

점검 순서

확인내용
1병합된 매니페스트에 INTERNET 권한이 실제로 있는가 (Android Studio의 Merged Manifest 탭)
2cacheMode를 어디선가 LOAD_CACHE_ONLY로 고정하고 있지 않은가
3blockNetworkLoads가 켜진 채 남아 있지 않은가
4실패하는 시점이 버튼을 누른 직후·뒤로가기에 몰려 있는가 → POST 문제
5같은 URL을 기기의 크롬으로 열어 서버 자체는 정상인지 확인
6앱 데이터를 지우고 첫 실행에서 재현되는가 (캐시가 빈 상태가 가장 잘 드러난다)

자주 묻는 질문

Q. 권한도 있고 캐시 모드도 기본값인데 계속 납니다.

실패하는 시점을 보세요. 버튼을 누른 직후나 뒤로가기에서만 난다면 POST 히스토리 문제라 설정으로는 안 풀립니다. 서버 쪽 리다이렉트(PRG) 또는 앱에서의 재로드로 접근해야 합니다.

Q. 크롬에서는 되는데 웹뷰에서만 납니다.

정상입니다. 크롬은 자체 캐시와 네트워크 스택 설정을 쓰고, 웹뷰는 앱이 지정한 설정을 씁니다. 비교 대상이 아니라 서버가 살아 있다는 확인용으로만 쓰세요.

Q. 에뮬레이터에서만 납니다.

에뮬레이터의 네트워크가 끊겨 있거나 프록시 설정이 남아 있는 경우가 많습니다. 브라우저 앱으로 아무 사이트나 열어 네트워크 자체를 먼저 확인하세요.

Q. 재시도 코드를 넣었더니 화면이 계속 깜빡입니다.

재시도 플래그가 없어 무한 루프에 빠진 것입니다. 한 번만 재시도하도록 막고, 성공 시(onPageFinished) 플래그를 초기화하세요.

정리

ERR_CACHE_MISS"캐시에서 못 찾았고, 네트워크로도 못 갔다"는 신호입니다. 그래서 답이 하나가 아닙니다. 권한이 없어서 네트워크로 못 간 것인지, 캐시 모드가 네트워크를 막은 것인지, 애초에 캐시가 있을 수 없는 POST 페이지인지를 먼저 가르는 것이 해결의 전부입니다.

가장 빠른 진단은 앱 데이터를 지우고 첫 실행에서 재현해 보는 것입니다. 그때 모든 페이지가 실패하면 권한, 특정 화면만 실패하면 캐시 모드, 버튼을 누른 뒤에만 실패하면 POST입니다.

728x90
반응형
LIST
728x90
반응형
SMALL

어느 날 아침 애드몹에서 메일이 왔습니다. 무효 활동으로 계정이 30일 정지되었고, 그 기간 광고는 한 건도 게재되지 않는다는 내용이었습니다. 며칠 뒤에는 애드센스까지 같은 사유로 멈췄습니다. 게시자 코드가 같기 때문에 앱에서 난 문제가 웹 수익까지 끊어버린 것입니다.

제일 먼저 든 생각은 "내가 눌렀나"였습니다. 아닙니다. 개발 중에는 테스트 광고 단위만 씁니다. 그다음은 "에뮬레이터인가"였고, 이것도 아니었습니다. 며칠에 걸쳐 애드몹 리포트를 기기별·시간별로 쪼개 보고 나서야 진짜 범인을 찾았습니다.

앱을 올릴 때마다 구글이 돌리는 자동 검사 로봇이 제 배너를 눌렀습니다.

실측 — 한 시간에 34번, CTR 106%

애드몹 리포트를 기기 모델 차원으로 나눠 보면 범인이 바로 드러납니다. 정지 이틀 전 오후 두 시대에, 제 앱 하나에서 이런 숫자가 찍혀 있었습니다.

항목
기기 모델OnePlus 8 Pro (Android 11)
발생 시간특정 한 시간 안에 집중
노출32
클릭34
CTR106%
계정 전체 30일 클릭에서 차지하는 비중51%
CTR 106%가 핵심 증거입니다. 사람은 노출보다 많이 클릭할 수 없습니다. 배너 한 장을 화면에 띄운 채로 같은 자리를 반복해서 누르는, 자동화된 입력에서만 나오는 수치입니다. 저는 그 기기를 가진 적이 없습니다.

이 기기는 누구인가

OnePlus 8 Pro / Android 11 조합은 구글이 앱 바이너리를 검사할 때 쓰는 자동화 기기입니다. 앱을 업로드하면 구글은 실제 기기에서 앱을 설치하고 실행해 크래시·정책 위반을 확인합니다. 그 로봇이 화면을 훑다가 배너를 반복해서 탭한 것입니다.

여기서 제가 한 번 크게 틀렸습니다. 처음에는 이것을 "Play 사전 출시 보고서"라고 단정하고 그 위에 대책을 쌓았습니다. 틀렸습니다.

사전 출시 보고서가 아닙니다. 사전 출시 보고서는 비공개 테스트 트랙에 올릴 때만 생성됩니다. 제가 운영하는 앱 중 그 트랙을 가진 것은 하나뿐인데, 나머지 앱에도 똑같이 이 기기가 찾아왔습니다. 즉 사전 출시 보고서와는 별개의 심사 자동화이고, 그래서 콘솔에서 끌 수 없습니다. "보고서를 끄면 된다"는 조언은 이 문제를 해결하지 못합니다.

정리하면 이렇습니다. 배포가 곧 자동 검사이고, 자동 검사가 곧 무효 클릭입니다. 가드 없이 실광고가 들어간 빌드를 올리는 행위 자체가 위험을 만듭니다.

흔히 하는 오해 두 가지

오해 1 — "에뮬레이터 때문이다"

아닙니다. 애드몹은 에뮬레이터를 알아서 테스트 기기로 취급합니다. 제 계정도 90일 실측에서 에뮬레이터 노출이 0이었습니다. 반면 심사 로봇은 실기기라서 그 필터에 걸리지 않습니다.

오해 2 — "구글 IP라서 알아서 걸러준다"

웹에 이런 글이 많지만, 제 계정 정지가 그 반증입니다. 걸러줬다면 애초에 클릭 34건이 리포트에 집계되지 않았을 것입니다.

막는 방법 — 로봇에게는 광고를 요청하지 않는다

정책을 우회하는 이야기가 아닙니다. 방향은 정반대로, 구글이 요구하는 "무효 활동 방지"를 개발자가 코드로 이행하는 것입니다. 테스트 환경에 실광고를 띄우지 않는 것과 같은 원칙입니다.

제가 쓰는 가드는 세 겹입니다. 한 겹만으로는 새는 구멍이 있었습니다.

object AdGuard {

    /** 광고를 로드해도 되는 환경인가 */
    fun adsAllowed(context: Context): Boolean {
        if (isTestLab(context)) return false      // 1겹
        if (isReviewDevice()) return false        // 2겹
        return true
    }

    // 1겹 — Firebase Test Lab 계열 자동화
    private fun isTestLab(context: Context): Boolean =
        "true" == Settings.System.getString(
            context.contentResolver, "firebase.test.lab")

    // 2겹 — 심사 자동 검사 기기 모델
    private fun isReviewDevice(): Boolean =
        Build.MODEL == "OnePlus8Pro"
}

그리고 광고를 로드하는 모든 지점에서 이 함수를 통과해야만 요청이 나가도록 배선합니다. 지점 하나라도 빠지면 그 자리에서 다시 터집니다.

// 배너
if (AdGuard.adsAllowed(this)) {
    adView.loadAd(AdRequest.Builder().build())
} else {
    adView.visibility = View.GONE   // 자리도 비운다
}

// 전면 · 보상형도 동일하게
if (AdGuard.adsAllowed(this)) {
    InterstitialAd.load(this, unitId, request, callback)
}

3겹 — 클릭 패턴 자체를 본다

모델명은 언젠가 바뀔 수 있습니다. 그래서 기기 정보와 무관하게 비정상적인 클릭 리듬이면 그 세션의 광고를 내리는 겹을 하나 더 뒀습니다.

// 같은 세션에서 광고 클릭이 짧은 간격으로 반복되면
// 이후 광고 요청을 그 세션 동안 중단한다
private val clickTimes = ArrayDeque<Long>()

fun onAdClicked() {
    val now = SystemClock.elapsedRealtime()
    clickTimes.addLast(now)
    while (clickTimes.isNotEmpty() && now - clickTimes.first() > 60_000) {
        clickTimes.removeFirst()
    }
    if (clickTimes.size >= 3) blockedForSession = true
}
세 겹으로 나눈 이유 — 1겹은 Test Lab 계열만 잡고, 2겹은 모델명이 바뀌면 무력화되며, 3겹은 클릭이 이미 한두 번 발생한 뒤에야 작동합니다. 서로의 빈틈을 메우는 조합이라 한 겹만 넣는 것은 사실상 안 넣은 것에 가깝습니다.

같이 점검해야 할 것들

확인내용
1모든 앱에 가드가 들어갔는가. 한 앱만 빠져도 계정 전체가 정지된다
2debug 빌드가 구글 테스트 광고 단위를 쓰는가. 빌드 타입 분기가 없는 앱이 남아 있기 쉽다
3종료 다이얼로그·뒤로가기 2회에 전면 광고를 붙이지 않았는가 (별도의 정책 위반 사유다)
4배너가 버튼과 붙어 있지 않은가. 로봇이든 사람이든 오클릭을 부른다
5애드몹 리포트를 기기 모델 차원으로 보는 습관. 이상 징후는 여기서 가장 먼저 보인다

정지된 뒤에 할 수 있는 것

솔직히 많지 않습니다. 30일 정지는 해제 신청으로 앞당겨지지 않았습니다. 다만 두 가지는 했습니다.

  • 무효 활동 신고 양식 제출 — 게시자 스스로 비정상 트래픽을 신고하는 창구가 있습니다. 원인 분석과 조치 내용을 함께 적었습니다.
  • 가드를 넣은 빌드를 전 앱에 배포 — 정지 기간에도 배포는 됩니다. 해제 시점에 이미 막혀 있는 상태로 맞이하는 것이 목적입니다.
가드가 실제로 작동하는지 확인은 정지 해제 후에만 가능합니다. 정지 기간에는 모든 기기에서 노출이 0이라, "가드가 막은 것"과 "정지라서 안 나온 것"이 구분되지 않습니다. 검증은 해제 이후로 미뤄야 합니다.

자주 묻는 질문

Q. 정말 제가 클릭한 게 아니라고 확신할 수 있나요?

기기 모델 차원으로 나눠 보면 확인됩니다. 제 손에 없는 모델에서, 한 시간에, CTR 100%가 넘게 발생했다면 사람의 사용 패턴이 아닙니다. 애드몹 리포트에서 기기 모델을 축으로 놓고 보는 것이 첫 단계입니다.

Q. 사전 출시 보고서를 끄면 해결되나요?

안 됩니다. 사전 출시 보고서는 비공개 테스트 트랙에서만 생성되는데, 그 트랙이 없는 앱에도 이 검사는 옵니다. 별개의 심사 절차이고 콘솔에서 끌 수 없습니다.

Q. 앱 광고 문제인데 왜 애드센스까지 멈추나요?

애드몹과 애드센스가 같은 게시자 코드를 공유하기 때문입니다. 앱과 웹을 함께 운영한다면, 앱 쪽 사고 하나가 웹 수익까지 끊는다는 점을 감안해야 합니다.

Q. 모델명으로 막는 게 정책 위반은 아닌가요?

무효 클릭을 줄이는 방향입니다. 구글은 게시자에게 무효 활동을 방지할 책임을 요구하고, 자동화 환경에는 실광고 대신 테스트 광고를 쓰도록 안내합니다. 심사 로봇에 광고를 요청하지 않는 것은 그 연장선입니다. 반대로 사람 사용자에게만 광고를 감추는 식의 조작은 당연히 안 됩니다.

Q. 광고가 안 나올 때의 오류 코드는 어디서 보나요?

그것은 이 글과 다른 문제입니다. 로드 자체가 실패하는 경우(NO_FILL 등)는 오류 코드별로 원인이 갈립니다.

정리

무효 클릭이라고 하면 대부분 "내가 눌렀나", "누가 악의적으로 눌렀나"를 먼저 떠올립니다. 저는 배포 과정 자체가 클릭원이었습니다. 앱을 올릴 때마다 검사 로봇이 실행되고, 그 로봇이 배너를 누릅니다.

대책은 단순합니다. 자동화 환경에는 광고를 요청하지 않는 것, 그리고 그 판단을 모든 광고 로드 지점에 예외 없이 배선하는 것입니다. 앱을 여러 개 운영한다면 한 개라도 빠지면 소용이 없습니다. 계정은 앱 단위가 아니라 게시자 단위로 정지되기 때문입니다.

728x90
반응형
LIST
728x90
반응형
SMALL

가장 잡기 어려운 버그는 내 손에서 재현되지 않는 버그입니다. 제 폰에서도, 에뮬레이터에서도, 실기기 서너 대에서도 멀쩡한 앱이 구글에 올리기만 하면 심사 단계에서 "시작 후 즉시 종료"로 잡혔습니다.

원인은 광고 동의창(UMP)이었습니다. 정확히는 그 동의창이 제 폰에서는 아예 뜨지 않는다는 사실이었습니다.

증상

환경결과
내 폰 · 에뮬레이터 · 지인 기기정상
구글 심사 자동 검사 · 사전 출시 보고서실행 직후 종료
Crashlytics기기 수는 적고, 특정 모델·특정 시간대에만 몰려 있음

재현이 안 되니 로그도 못 봅니다. "테스트 기기가 이상한 거겠지" 하고 넘기기 딱 좋은 형태입니다. 그러다 그 빌드가 그대로 출시됩니다.

왜 내 폰에서는 재현되지 않았나

UMP(User Messaging Platform)는 개인정보 처리 동의를 받아야 하는 지역에서만 동의 폼을 띄웁니다. 유럽 경제 지역(EEA)·영국 등이 대상이고, 한국에서 실행하면 폼이 뜰 이유가 없습니다.

즉 제 폰에서는 동의창을 띄우는 코드 경로 자체가 실행된 적이 없었습니다. 문제가 없었던 게 아니라, 문제가 있는 코드를 한 번도 지나간 적이 없었던 것입니다.

구글의 검사 환경은 지역이 다를 수 있습니다. 그래서 검사 기기에서는 동의 폼이 실제로 뜨고, 그 순간 앱이 죽었습니다. "내 폰에서는 되는데"라는 말이 성립하지 않는 대표적인 구조입니다.

진짜 원인 — 초기화 순서

동의창은 앱이 처음 뜨는 순간, 화면이 아직 자리를 잡기 전에 액티비티 위에 올라옵니다. 그래서 동의 폼 요청을 화면 설정보다 먼저 부르면 창이 붙는 시점이 어긋납니다.

제 경우 enableEdgeToEdge()보다 동의 초기화를 에 뒀던 것이 문제였습니다. 이 호출은 창이 시스템 바 영역까지 그리도록 window 설정을 바꾸는 작업인데, 그 전에 동의 폼이 같은 창에 붙으려 하면 앱이 시작 단계에서 무너졌습니다.

// ❌ 죽는다 — 내 폰에서는 폼이 안 떠서 티가 안 난다
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        gatherConsentAndInitAds()     // ← 여기서 폼이 뜨는 지역이면 사고
        enableEdgeToEdge()
        setContent { MyApp() }
    }
}
// ✅ 화면 설정을 먼저 끝내고 나서 동의를 요청한다
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()            // ← window 설정이 먼저
        super.onCreate(savedInstanceState)

        setContent { MyApp() }

        gatherConsentAndInitAds()     // ← 화면이 준비된 뒤
    }
}
원칙 하나로 외우면 됩니다. 창(window)의 성격을 바꾸는 호출은 가장 먼저, 창 위에 무언가를 띄우는 호출은 가장 나중에. 스플래시 화면 API, 테마 적용, edge-to-edge 설정이 앞쪽이고, 동의창·업데이트 유도 팝업·리뷰 요청이 뒤쪽입니다.

동의 요청 코드 — 전체 흐름

UMP는 "정보 갱신 → 필요하면 폼 표시 → 광고 초기화" 3단계입니다. 각 단계에서 흔히 빠뜨리는 것을 함께 적었습니다.

private var adsInitialized = AtomicBoolean(false)

fun gatherConsentAndInitAds(activity: Activity) {
    val params = ConsentRequestParameters.Builder().build()
    val info = UserMessagingPlatform.getConsentInformation(activity)

    info.requestConsentInfoUpdate(activity, params,
        {
            // 폼이 필요하면 띄우고, 아니면 즉시 콜백
            UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { error ->
                if (error != null) {
                    Log.w("Consent", "${error.errorCode}: ${error.message}")
                }
                // ★ 실패해도 진행한다 — 여기서 멈추면 광고가 영영 안 붙는다
                if (info.canRequestAds()) initAds(activity)
            }
        },
        { error ->
            Log.w("Consent", "update 실패 ${error.errorCode}: ${error.message}")
            // 네트워크 실패 등 — 이전에 저장된 동의 상태로 판단
            if (info.canRequestAds()) initAds(activity)
        }
    )
}

private fun initAds(context: Context) {
    if (!adsInitialized.compareAndSet(false, true)) return   // 중복 초기화 방지
    MobileAds.initialize(context)
}
실패 경로에서 아무것도 안 하는 코드가 많습니다. 네트워크가 잠깐 끊겨 requestConsentInfoUpdate가 실패하면, 그 세션에서는 광고가 한 개도 안 붙습니다. 사용자는 앱이 멀쩡해 보이니 아무도 신고하지 않고, 수익만 조용히 사라집니다.

핵심 — 지역을 강제해서 직접 재현하기

이 문제의 진짜 해법은 코드 수정이 아니라 재현 방법을 갖는 것입니다. UMP는 디버그 설정으로 지역을 EEA로 강제할 수 있습니다. 이걸 모르면 영원히 자기 손에서 확인할 수 없습니다.

// ⚠ 디버그 빌드에서만
val debugSettings = ConsentDebugSettings.Builder(activity)
    .setDebugGeography(
        ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_EEA)
    .addTestDeviceHashedId("여기에 로그캣에 찍힌 해시 ID")
    .build()

val params = ConsentRequestParameters.Builder()
    .setConsentDebugSettings(debugSettings)
    .build()

테스트 기기 해시 ID는 앱을 한 번 실행하면 로그캣에 찍힙니다. Use new ConsentDebugSettings.Builder().addTestDeviceHashedId(...) 형태의 안내 로그를 찾으면 됩니다.

// 테스트를 반복할 때는 저장된 동의 상태를 지운다
UserMessagingPlatform.getConsentInformation(activity).reset()
reset()을 안 하면 한 번 동의한 뒤로는 폼이 다시 안 뜹니다. "분명 어제는 떴는데 오늘은 안 뜬다"의 대부분이 이것입니다. 그리고 이 디버그 설정 코드가 릴리스 빌드에 섞여 들어가지 않도록 빌드 타입으로 갈라두세요.

재현 안 되는 크래시를 잡는 일반적인 순서

순서확인
1내 환경에서 실행되지 않는 코드 경로가 무엇인지 먼저 목록으로 적는다 (지역·언어·권한 거부·저사양·최초 실행)
2그중 강제로 켤 수 있는 것을 찾는다 (UMP는 디버그 지역, 권한은 거부 상태로 실행 등)
3Crashlytics에서 기기 모델·시간대·앱 버전이 몰려 있는지 본다. 몰려 있으면 사람이 아니라 자동화일 확률이 높다
4앱 시작 구간의 호출을 순서대로 나열하고, 창 설정 → 화면 구성 → 팝업 순인지 확인한다
5비공개 테스트 트랙에 먼저 올려 사전 출시 보고서를 받아본다 (내가 못 가진 기기들이 대신 실행해 준다)
Crashlytics 콘솔의 기본 필터는 치명적 오류만 보여줍니다. 앱이 죽지 않고 기능만 실패하는 non-fatal 기록은 필터를 바꿔야 보입니다. "크래시가 없다"와 "문제가 없다"는 다릅니다.

화면이 잘리는 문제라면 — 다른 글

앱이 죽는 것이 아니라 상태바·내비게이션 바에 UI가 겹치거나 잘리는 증상이라면 원인이 다릅니다. targetSdk를 35 이상으로 올렸을 때의 edge-to-edge 강제 적용 문제이고, 인셋 처리로 해결합니다. 그 내용은 별도 글에서 다뤘습니다.

자주 묻는 질문

Q. 한국 서비스만 할 건데 UMP를 꼭 넣어야 하나요?

앱 스토어는 국가를 가리지 않고 노출되고, 광고 정책상 대상 지역 사용자가 들어오면 동의 처리가 필요합니다. 그리고 넣는 것 자체보다 넣고 나서 그 경로를 한 번도 테스트하지 않는 것이 더 위험합니다.

Q. 동의창이 아예 안 뜹니다. 잘못된 건가요?

대상 지역이 아니면 안 뜨는 것이 정상입니다. 확인하려면 디버그 지역을 EEA로 강제하고 reset() 후 다시 실행하세요.

Q. 동의를 거부하면 광고를 못 붙이나요?

맞춤 광고를 못 붙이는 것이고, canRequestAds()가 참이면 비맞춤 광고는 가능합니다. 이 값을 보고 분기해야지, 동의 여부를 직접 해석하려 들면 어긋납니다.

Q. 검사 기기에서만 죽는데 그냥 무시하면 안 되나요?

그 검사를 통과하지 못하면 배포가 막히거나 정책 문제로 이어집니다. 더 중요한 건, 검사 기기에서 죽는다는 것은 대상 지역의 실제 사용자에게도 똑같이 죽는다는 뜻이라는 점입니다.

Q. 동의 초기화를 Application에서 하면 안 되나요?

동의 폼은 액티비티가 있어야 띄울 수 있습니다. Application에서는 폼을 띄울 수 없으므로, 광고 SDK 초기화와 동의 요청 시점을 분리해 설계해야 합니다.

정리

"내 폰에서는 되는데"는 대부분 내 폰에서는 그 코드가 실행되지 않았다는 뜻입니다. 광고 동의창처럼 지역에 따라 갈리는 코드는 국내에서 아무리 테스트해도 한 번도 지나가지 않습니다.

대응은 두 가지입니다. 첫째, 창 설정을 먼저, 팝업을 나중에 부르는 순서를 지킬 것. 둘째, 디버그 지역 강제로 그 경로를 직접 밟아볼 것. 두 번째가 훨씬 중요합니다. 재현할 수 있으면 고치는 건 십 분이면 끝나기 때문입니다.

728x90
반응형
LIST
728x90
반응형
SMALL

코드는 하나도 안 건드리고 targetSdk만 35로 올렸습니다. 그런데 안드로이드 15 기기에서 앱을 켜니 제목이 상태바에 파묻혀 있고, 화면 맨 아래 버튼은 내비게이션 바에 가려 눌리지 않습니다. 구형 기기에서는 멀쩡합니다.

버그가 아니라 안드로이드 15의 강제 사항입니다.

무슨 일이 일어난 건가

targetSdk = 35인 앱이 안드로이드 15에서 실행되면, OS가 edge-to-edge를 강제로 적용합니다. 앱 화면이 상태바와 내비게이션 바 아래까지 확장되어 그려지고, 시스템 바는 투명해집니다.

의도는 좋습니다. 화면을 끝까지 쓰는 요즘 디자인이 기본이 되는 것이죠. 문제는 앱이 그 사실을 모르면 중요한 UI가 시스템 바 밑에 깔린다는 점입니다.

상황증상
상단 툴바·제목상태바 시계·배터리와 겹쳐 보임
하단 버튼·탭바내비게이션 바에 가려 탭이 안 먹힘
스크롤 리스트마지막 항목이 끝까지 안 올라옴
다이얼로그·바텀시트제스처 바와 겹침

급하다면 — 임시 opt-out (기한이 있다)

당장 배포해야 한다면 강제 적용을 끌 수 있습니다.

<!-- res/values/themes.xml -->
<style name="Theme.MyApp" parent="Theme.Material3.DayNight">
    <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
이것은 유예이지 해결이 아닙니다. 이 속성은 targetSdk 35에서만 유효합니다. targetSdk 36으로 올리는 순간 무시되고, 강제 적용이 그대로 돌아옵니다. 그리고 아래에서 보듯 36으로 올려야 하는 시한이 이미 정해져 있습니다.

왜 유예 기간이 짧은가 — Play의 타깃 API 요구

구글 플레이는 매년 타깃 API 하한을 올립니다. 2026년 기준으로 정리하면 이렇습니다.

구분요구 사항
신규 앱 · 앱 업데이트 제출2026년 8월 31일부터 API 36(안드로이드 16) 이상을 타깃해야 제출 가능
기존 앱(업데이트 안 하는 경우)API 35 이상이어야 새 사용자에게 계속 노출됨. 그보다 낮으면 자기 타깃보다 높은 OS 기기에서 신규 설치 대상에서 빠진다
기한 연장사유가 있으면 11월 1일까지 연장 신청 가능
폼팩터 예외Wear OS·Automotive는 API 35 이상, Android TV·XR은 API 34 이상

앱을 계속 업데이트할 생각이라면 36으로 가야 하고, 36으로 가면 opt-out은 사라집니다. 인셋 처리를 미룰수록 나중에 한꺼번에 밀려옵니다.

현실적인 순서는 이렇습니다. ① opt-out으로 급한 배포를 넘기고 → ② 인셋을 제대로 처리한 뒤 → ③ opt-out을 지우고 targetSdk 36으로 올린다. ①에서 멈추면 8월 말 이후 업데이트 자체가 막힙니다.

제대로 고치기 — Compose

Compose에서는 enableEdgeToEdge()를 호출하고, 인셋을 필요한 컴포저블에만 먹이는 것이 기본입니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()        // super 앞뒤 어디든 되지만 setContent 전에
        super.onCreate(savedInstanceState)

        setContent { MyApp() }
    }
}

가장 간단한 방법은 Scaffold가 주는 패딩을 실제로 쓰는 것입니다. Material3의 Scaffold는 시스템 바 인셋을 계산해 innerPadding으로 넘겨줍니다. 이걸 무시하고 버리는 코드가 사고의 8할입니다.

@Composable
fun MyApp() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("제목") }) }
    ) { innerPadding ->
        // ❌ innerPadding 을 안 쓰면 툴바 밑에 깔린다
        // LazyColumn { ... }

        // ✅ 반드시 적용
        LazyColumn(
            modifier = Modifier.padding(innerPadding)
        ) { /* ... */ }
    }
}

스크롤 화면은 padding이 아니라 contentPadding

리스트에 Modifier.padding()을 주면 스크롤 영역 자체가 줄어들어 콘텐츠가 시스템 바 밑으로 자연스럽게 흘러가는 느낌이 사라집니다. 리스트는 contentPadding을 써야 합니다.

LazyColumn(
    contentPadding = innerPadding      // 항목만 밀린다, 스크롤은 끝까지
) {
    items(list) { /* ... */ }
}

Scaffold를 안 쓰는 화면

Box(
    modifier = Modifier
        .fillMaxSize()
        .windowInsetsPadding(WindowInsets.safeDrawing)   // 시스템 바 + 컷아웃
) { /* ... */ }

// 아래쪽만 필요할 때
Modifier.windowInsetsPadding(
    WindowInsets.navigationBars.only(WindowInsetsSides.Bottom))
인셋포함하는 것쓰는 곳
safeDrawing시스템 바 + 디스플레이 컷아웃 + IME대부분의 경우 이것
systemBars상태바 + 내비게이션 바키보드와 무관한 화면
ime소프트 키보드입력창이 가려질 때
safeContentsafeDrawing + 제스처 영역스와이프 제스처와 충돌할 때

제대로 고치기 — View · XML

XML 레이아웃이라면 리스너로 인셋을 받아 패딩에 반영합니다.

override fun onCreate(savedInstanceState: Bundle?) {
    enableEdgeToEdge()
    super.onCreate(savedInstanceState)
    setContentView(binding.root)

    ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets ->
        val bars = windowInsets.getInsets(
            WindowInsetsCompat.Type.systemBars()
                or WindowInsetsCompat.Type.displayCutout()
        )
        view.updatePadding(
            left = bars.left, top = bars.top,
            right = bars.right, bottom = bars.bottom
        )
        WindowInsetsCompat.CONSUMED
    }
}
CONSUMED를 반환하면 자식 뷰에는 인셋이 전달되지 않습니다. 루트에서 한 번에 처리할 때만 쓰세요. 하위 뷰에서도 인셋이 필요하다면 windowInsets를 그대로 반환해야 합니다. "리스너를 달았는데 자식 쪽이 안 먹는다"는 대부분 이것입니다.

키보드가 입력창을 가릴 때

edge-to-edge에서는 adjustResize만으로 부족한 경우가 많습니다. IME 인셋을 직접 반영하세요.

val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime())
view.updatePadding(bottom = maxOf(bars.bottom, ime.bottom))

상태바 아이콘이 안 보일 때

시스템 바가 투명해지면서 밝은 배경에 흰 아이콘이 겹쳐 시계가 안 보이는 일이 생깁니다. 아이콘 명암을 앱이 지정해야 합니다.

WindowCompat.getInsetsController(window, window.decorView).apply {
    isAppearanceLightStatusBars = true      // 밝은 배경 → 어두운 아이콘
    isAppearanceLightNavigationBars = true
}
다크 테마를 지원한다면 이 값을 테마 전환 시점마다 갱신해야 합니다. 한 번만 설정해 두면 다크 모드에서 아이콘이 안 보이게 됩니다.

앱이 아예 안 뜬다면 — 다른 문제다

화면이 잘리는 것이 아니라 실행되자마자 죽는다면 edge-to-edge 자체가 아니라 enableEdgeToEdge()와 다른 초기화의 호출 순서 문제일 수 있습니다. 특히 광고 동의(UMP) 초기화를 이 호출보다 앞에 두면, 내 폰과 에뮬레이터에서는 멀쩡한데 구글 자동 검사에서만 앱이 시작조차 못 하는 증상이 나옵니다. 그 건은 별도 글에서 다룹니다.

점검 순서

확인내용
1ScaffoldinnerPadding을 실제로 쓰고 있는가 — 안 쓰고 버리는 코드가 가장 흔하다
2리스트에 padding 대신 contentPadding을 썼는가
3커스텀 하단 버튼·FAB에 navigationBars 인셋이 반영됐는가
4다이얼로그·바텀시트·전체화면 화면도 따로 확인했는가 (Scaffold 밖이라 자동 처리가 안 된다)
5제스처 내비게이션과 3버튼 내비게이션 양쪽에서 확인했는가 — 인셋 높이가 다르다
6가로 모드·폴더블 펼침·디스플레이 컷아웃(노치) 기기에서 확인했는가
7키보드를 올렸을 때 입력창이 가려지지 않는가
8opt-out 속성을 넣어뒀다면, 36으로 올리기 전에 지웠는가

자주 묻는 질문

Q. 내 폰에서는 멀쩡한데요?

기기의 OS 버전이 안드로이드 15 미만이면 강제 적용이 일어나지 않습니다. 15 이상 기기나 에뮬레이터에서 확인해야 합니다.

Q. opt-out을 계속 쓰면 안 되나요?

targetSdk 35인 동안만 됩니다. 36으로 올리면 이 속성은 무시되고, 앱 업데이트를 제출하려면 36으로 올려야 하는 시점이 정해져 있습니다. 결국 시간 문제입니다.

Q. Compose Material3를 쓰는데도 겹칩니다.

TopAppBar·BottomAppBar는 자체적으로 인셋을 처리하지만, Scaffold가 넘겨주는 innerPadding을 콘텐츠에 적용하는 것은 개발자 몫입니다. 이 패딩을 안 쓰면 본문이 바 밑으로 들어갑니다.

Q. Flutter · React Native도 같은 문제가 나나요?

납니다. 네이티브 컨테이너가 edge-to-edge로 바뀌기 때문입니다. React Native는 react-native-safe-area-context의 SafeAreaView로, Flutter는 SafeArea 위젯으로 대응하고, 프레임워크·플러그인 버전을 함께 올려야 하는 경우가 많습니다.

Q. 상태바를 그냥 불투명하게 칠하면 안 되나요?

배경색을 칠하는 방식은 안드로이드 15에서 더 이상 의도대로 동작하지 않습니다. 상단 바의 배경을 시스템 바 밑까지 확장하고, 그 안의 텍스트·아이콘만 인셋만큼 밀어내는 것이 권장 방식입니다.

정리

안드로이드 15의 edge-to-edge 강제는 "앱이 화면 전체를 그리게 하고, 시스템 바를 피하는 책임은 앱에 준다"는 변경입니다. 그래서 대응의 핵심도 하나입니다. 인셋을 받아서 실제로 반영하는 것.

windowOptOutEdgeToEdgeEnforcement는 급한 배포를 넘기는 용도로만 쓰세요. targetSdk 36에서는 통하지 않고, Play의 타깃 API 요구 때문에 36으로 올려야 하는 시점이 이미 정해져 있습니다. 미룰수록 한꺼번에 밀려옵니다.

728x90
반응형
LIST
728x90
반응형
SMALL

ETF를 고를 때 총보수만 비교하는 경우가 많습니다. 그런데 연 0.01% 보수 차이보다 훨씬 큰 손실이 매매하는 순간에 발생할 수 있습니다. 괴리율 때문입니다.

괴리율과 추적오차는 이름이 비슷해 자주 섞여 쓰이지만 원인도 다르고 대응법도 다릅니다. 하나는 내가 피할 수 있는 것이고, 하나는 상품을 고를 때 봐야 하는 것입니다.

한 장으로 보는 차이

구분괴리율추적오차
무엇의 차이인가시장가격순자산가치(NAV)ETF 수익률기초지수
원인시장에서의 수급, 유동성운용 방식, 보수, 세금, 현금 보유
성격순간적 · 매매 시점의 문제누적적 · 상품 자체의 문제
내가 할 수 있는 것매매 시간과 호가로 피할 수 있음상품 선택 단계에서 걸러야 함

괴리율 — 지금 이 가격이 제값인가

ETF에는 두 개의 가격이 있습니다.

  • NAV(순자산가치) — ETF가 담고 있는 자산의 실제 가치. 하루 한 번 계산됩니다.
  • iNAV(실시간 추정 순자산가치) — 장중에 실시간으로 추정한 값
  • 시장가격 — 지금 거래되는 값

괴리율은 이 둘의 차이입니다.

괴리율(%) = (시장가격 - NAV) ÷ NAV × 100

괴리율이 양수(+)면 제값보다 비싸게 거래되는 것이고, 음수(-)면 싸게 거래되는 것입니다. 비싸게 사서 싸게 파는 일이 반복되면, 지수가 올랐는데도 내 수익률은 시원치 않은 상황이 생깁니다.

왜 벌어지는가

ETF에는 LP(유동성공급자)가 붙어 있습니다. 시장가격이 iNAV에서 벌어지면 LP가 반대편 호가를 내서 좁혀 주는 역할을 합니다. 그래서 평소에는 괴리율이 거의 없습니다.

문제는 LP가 호가를 내지 않는 시간대가 있다는 것입니다.

★ LP 호가가 없는 시간 — 이때 사면 손해 본다

LP는 아래 시간대에 호가 제출 의무가 없습니다.

시간대구분
08:30 - 09:00장 시작 전 동시호가
09:00 - 09:05장 시작 직후 5분
15:20 - 15:30장 마감 동시호가
이 시간대에는 괴리율을 좁혀 줄 주체가 없습니다. 특히 장 시작 직후 5분은 많은 사람이 매수 주문을 넣는 시간이라 가격이 제값에서 크게 벌어지기 쉽습니다. 거래량이 적은 ETF일수록 심합니다.

실천 원칙은 단순합니다.

  • 장 시작 5분과 마감 10분을 피해 09:05 이후 - 15:20 이전에 매매한다
  • 시장가 대신 지정가로 주문한다 (거래량이 적은 ETF일수록 중요)
  • 주문 전 iNAV와 현재가를 비교한다

거래소가 정한 관리 기준

LP는 종가 기준 괴리율을 일정 수준 안에서 관리할 의무가 있고, 이 기준은 강화되는 추세입니다.

기초자산괴리율 관리 기준
국내 자산2% (기존 3%에서 강화)
해외 자산5% (기존 6%에서 강화)

해외 자산 기준이 더 느슨한 데는 이유가 있습니다.

해외 ETF의 괴리율이 큰 이유

국내에 상장된 미국 지수 ETF는 한국 장중에 미국 시장이 닫혀 있습니다. 기초자산의 실시간 가격이 없는 상태에서 거래되는 것이죠. 그래서 시장의 기대가 가격에 먼저 반영되면서 괴리가 벌어지기 쉽습니다.

미국 시장이 급등락한 다음 날 아침, 국내 상장 미국 ETF가 제값보다 비싸게 시작하는 경우를 흔히 볼 수 있는데 대부분 이 때문입니다. 이런 날 장 시작 직후에 시장가로 사면 그 프리미엄을 그대로 부담하게 됩니다.

추적오차 — 이 상품이 지수를 잘 따라가는가

추적오차는 ETF가 기초지수를 얼마나 정확히 따라가는지를 나타냅니다. 매매 타이밍과 무관하게 상품 자체의 품질입니다.

주요 원인은 이렇습니다.

원인설명
총보수매일 조금씩 차감되므로 구조적으로 지수보다 뒤처짐
현금 보유분배금 지급 등을 위해 일부 현금을 들고 있으면 지수를 100% 따라가지 못함
복제 방식지수 전 종목을 담는지(완전복제), 일부만 담는지(부분복제)에 따라 차이
세금·거래비용배당에 붙는 세금, 리밸런싱 매매 비용

같은 지수를 추종하는 ETF가 여러 개일 때, 총보수가 조금 더 싼데 추적오차가 큰 상품이 있습니다. 이 경우 보수 차이보다 추적오차 손실이 클 수 있으니 둘을 같이 봐야 합니다.

고르는 순서: ① 거래량(유동성)이 충분한가 → ② 추적오차가 작은가 → ③ 총보수가 싼가. 거래량이 너무 적으면 보수가 아무리 싸도 매매할 때마다 괴리율로 잃습니다.

어디서 확인하나

  1. 운용사 홈페이지 — 상품 페이지에 NAV, 괴리율, 추적오차가 공시됩니다. 가장 정확합니다.
  2. KRX 정보데이터시스템 — 전 종목의 괴리율·추적오차를 한자리에서 비교할 수 있습니다.
  3. HTS·MTS — 현재가 화면에서 iNAV를 표시해 줍니다. 주문 직전에 현재가와 비교하세요.

자주 묻는 질문

Q. 괴리율이 크면 무조건 나쁜 상품인가요?

아닙니다. 괴리율은 순간의 가격 상태라서 상품의 품질과 직접 연결되지 않습니다. 다만 괴리율이 상시로 큰 종목은 거래량이 부족하다는 신호이므로 피하는 편이 낫습니다.

Q. 추적오차가 0인 ETF도 있나요?

없습니다. 총보수가 매일 차감되기 때문에 구조적으로 지수보다 조금씩 뒤처집니다. 중요한 건 0인지가 아니라 같은 지수를 추종하는 다른 상품과 비교했을 때 어느 쪽이 작은가입니다.

Q. 장 시작하자마자 사면 정말 손해인가요?

항상 그런 건 아니지만 불리해질 확률이 높습니다. 09:00부터 09:05까지는 LP 호가 의무가 없어 가격이 제값에서 벌어져 있을 수 있습니다. 급하지 않다면 09:05 이후를 권합니다.

Q. 적립식으로 매달 사는데도 신경 써야 하나요?

오히려 더 중요합니다. 매달 반복되면 누적되기 때문입니다. 자동 매수를 쓴다면 체결 시간이 언제로 설정되는지 확인해 보세요. 장 시작 직후로 잡혀 있다면 조정할 만합니다.

정리

괴리율은 내가 피할 수 있고, 추적오차는 고를 때 걸러야 합니다.

  • 매매는 09:05 이후 - 15:20 이전, 지정가로
  • 거래량이 적은 ETF일수록 괴리율에 민감하게
  • 해외 지수 ETF는 급등락 다음 날 아침 프리미엄을 조심
  • 상품을 고를 때는 총보수만 보지 말고 거래량과 추적오차를 함께

연 0.01%의 보수 차이를 따지는 것보다, 매수 버튼을 5분 늦게 누르는 편이 더 큰 차이를 만드는 경우가 많습니다.

728x90
반응형
LIST

+ Recent posts