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

728x90
반응형
SMALL

다른 버그를 잡다가 계측 테스트를 Android 10 실기기에서 돌렸습니다. 평소에는 최신 기기로만 돌리던 것이었습니다.

java.lang.SecurityException: Unknown permission
    android.permission.READ_MEDIA_IMAGES

갤러리 테스트 세 건이 전부 이걸로 죽었습니다. 테스트가 실패한 게 아니라 본문이 시작조차 못 한 것입니다.

범인은 이 줄이었습니다.

// ❌ API 33 전용 권한을 모든 기기에 요구한다
@get:Rule(order = 0)
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
    Manifest.permission.CAMERA,
    Manifest.permission.ACCESS_FINE_LOCATION,
    Manifest.permission.READ_MEDIA_IMAGES,
)

원인 — 권한 이름이 API 33에서 갈린다

READ_MEDIA_IMAGESAPI 33(Android 13)에서 생긴 권한입니다. 그 미만 기기에는 그런 이름의 권한이 시스템에 아예 없습니다.

GrantPermissionRule은 테스트 본문 전에 이 권한들을 부여하려 하는데, 존재하지 않는 이름을 받으면 SecurityException을 던집니다. @Rule 단계에서 던지므로 @Before@Test도 실행되지 않습니다.

여기서 정신이 번쩍 들었던 건, 제 앱의 minSdk26이라는 사실이었습니다. 즉 갤러리 기능은 지원 범위의 상단 일부에서만 검증돼 왔습니다. 그 아래 구간은 테스트가 존재는 하는데 한 번도 돌아 본 적이 없었습니다.

고치는 법 — 매니페스트와 같은 경계로 가른다

@get:Rule(order = 0)
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
    Manifest.permission.CAMERA,
    Manifest.permission.ACCESS_FINE_LOCATION,
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
        Manifest.permission.READ_MEDIA_IMAGES
    else
        Manifest.permission.READ_EXTERNAL_STORAGE
)
매니페스트에는 이미 경계가 있을 겁니다.
사진을 읽는 앱이라면 READ_EXTERNAL_STORAGEandroid:maxSdkVersion="32"가 붙어 있을 것입니다. 즉 앱은 두 권한을 이미 33에서 가르고 있었고, 테스트만 그 사실을 몰랐습니다. 테스트의 권한 목록은 매니페스트를 따라가는 게 맞습니다.

더 나쁜 쪽 — 아무것도 검증하지 않으면서 늘 통과하던 테스트

위의 건은 그래도 빨간불이라 눈에 띕니다. 같은 날 발견한 다른 테스트가 훨씬 나빴습니다.

// ❌ "갤러리로 이동했는가"를 본다면서 빈 목록 문구를 확인한다
@Test
fun galleryContent_shown_afterOpeningGallery() {
    composeTestRule.onNodeWithContentDescription("갤러리").performClick()
    composeTestRule.onNodeWithText("저장된 사진이 없습니다").assertIsDisplayed()
}

이 테스트는 계속 통과하고 있었습니다. 이유가 문제입니다. 그 테스트 클래스는 사진 읽기 권한을 부여하지 않았고, 그래서 갤러리는 사진이 있든 없든 늘 비어 보였습니다.

즉 이 테스트는

  • 화면 이동을 검증한다고 이름을 붙였지만
  • 실제로는 목록이 비어 있는지를 보고 있었고
  • 권한이 없어서 영원히 비어 있었습니다

실패할 수 없는 초록불이었습니다. 권한을 제대로 넣자마자 곧바로 빨간불이 됐습니다.

고친 방식은 관심사에 맞는 것을 보게 한 것입니다. 이동을 확인하고 싶으면 목록 내용이 아니라 탭이 선택됐는지를 봅니다.

@Test
fun galleryScreen_retainsStateAfterTabSwitch() {
    composeTestRule.onNodeWithText(str(R.string.tab_settings)).performClick()
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText(str(R.string.tab_gallery)).performClick()
    composeTestRule.waitForIdle()
    // "무엇이 떴는가"가 아니라 "갤러리로 돌아왔는가"가 이 테스트의 관심사다
    composeTestRule.onNodeWithText(str(R.string.tab_gallery)).assertIsSelected()
}

"빈 상태"를 전제하는 테스트는 실패가 아니라 스킵이어야 한다

빈 목록 문구를 진짜로 확인하고 싶은 테스트도 물론 있습니다. 그건 기기에 사진이 없을 때만 성립하는 전제입니다. 사진이 있는 실기기에서 빨간불로 두면 진짜 회귀와 구분이 안 됩니다.

@Test
fun galleryScreen_showsEmptyMessageWhenNoPhotos() {
    assumeTrue("사진이 있는 기기에서는 빈 상태를 볼 수 없다", galleryIsEmpty())
    composeTestRule.onNodeWithText(str(R.string.gallery_empty)).assertIsDisplayed()
}

전제 판정은 화면과 같은 질의를 재사용한다

galleryIsEmpty()를 짤 때 경로를 테스트에 따로 적고 싶어집니다. 그러면 앱이 저장 폴더를 바꿨을 때 조용히 어긋납니다. 화면이 쓰는 질의 함수를 그대로 가져다 씁니다.

private fun galleryIsEmpty(): Boolean {
    val (selection, args) = galleryQuery(Build.VERSION.SDK_INT)   // 화면과 같은 질의
    val ctx = InstrumentationRegistry.getInstrumentation().targetContext
    val cursor = ctx.contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        arrayOf(MediaStore.Images.Media._ID), selection, args, null
    ) ?: return true
    cursor.use { return it.count == 0 }
}

무엇을 놓쳤나

이 테스트들이 못 잡고 지나간 것이 실제로 있었습니다. 같은 앱에서 SQLiteException: no such column: relative_path로 갤러리를 여는 순간 앱이 죽는 버그가 API 28 이하에 있었습니다. MediaStore.RELATIVE_PATH가 API 29부터인데 minSdk가 26이었던 것입니다.

갤러리 화면을 여는 계측 테스트가 분명히 있었는데도 못 잡았습니다. 그 테스트가 API 33 이상에서만 돌고 있었기 때문입니다. 자세한 내용은 따로 정리했습니다 — (MediaStore relative_path 편).

앱만 minSdk에서 돌려 보면 부족합니다.
테스트 자신이 minSdk에서 돌고 있는지를 따로 확인해야 합니다. 두 개는 다른 질문입니다.

일반화 — 초록불의 두 가지 뜻

이번 일을 한 문장으로 줄이면 이렇습니다.

초록불이 "검증됐다"는 뜻인지 "조건에 못 닿았다"는 뜻인지 갈라 봐야 합니다.

아래는 전부 결과 요약에서 통과처럼 보이는 것들입니다.

모양실제드러내는 법
assumeTrue 스킵그 경로를 안 밟았다실행 결과의 스킵 수를 매번 확인한다
권한 미부여화면이 늘 빈 상태라 무엇이든 통과권한을 준 상태에서 빨간불이 나는지 본다
단정문이 관심사와 다름이름과 검증 내용이 어긋남테스트 이름을 소리 내어 읽고 단정문과 대조한다
기기가 경계 밖지원 구간의 일부만 검증minSdk 기기·에뮬에서 한 번 돌린다
가장 확실한 방법은 음성 대조군입니다.
수정 전 코드를 넣고 그 테스트가 빨간불이 되는지 확인하세요. 빨간불이 안 되면 그 테스트는 버그를 잡지 못합니다. 초록불만 보고 넘기면 그 사실을 영영 모릅니다.

결과

권한 분기를 넣고 단정문을 바로잡은 뒤 두 환경에서 돌렸습니다.

실기기(Android 10) : 계측 42개 · 0 실패 · 2 스킵
에뮬레이터(전면 카메라 없음) : 계측 42개 · 0 실패 · 1 스킵

스킵 수가 환경마다 다른 것이 정상입니다. 그 차이가 곧 "이 환경에서는 어떤 전제가 성립하지 않았는가"를 말해 줍니다. 스킵이 0으로만 나오면 오히려 전제 처리를 안 한 것일 수 있습니다.

정리

  • GrantPermissionRuleAPI 33 전용 권한 이름을 그대로 넘기지 마세요. 33 미만에서 SecurityException으로 본문이 시작조차 못 합니다.
  • 권한 목록의 경계는 매니페스트의 maxSdkVersion과 같게 맞춥니다.
  • 권한을 안 준 테스트는 화면이 늘 비어 보여 아무거나 통과시킵니다.
  • "빈 상태" 전제는 실패가 아니라 스킵(assumeTrue)으로 처리합니다.
  • 전제 판정은 화면과 같은 질의를 재사용합니다.
  • 테스트도 minSdk에서 한 번 돌리세요. 앱을 돌린 것과는 별개의 확인입니다.

자주 묻는 것

CI에서는 최신 API 에뮬레이터만 쓰는데요?

흔한 구성이고, 그래서 이 문제가 오래 숨습니다. 전체 스위트를 두 배로 돌릴 필요는 없습니다. 저장소·권한·미디어를 건드리는 테스트 클래스만 minSdk 에뮬레이터에서 한 번 더 돌려도 이 부류는 걸러집니다.

@SdkSuppress로 33 미만을 제외하면 안 되나요?

그건 "33 미만에서는 검증하지 않겠다"고 선언하는 것입니다. 기능 자체가 33+ 전용이면 옳은 선택이지만, 갤러리처럼 minSdk 전 구간에서 동작해야 하는 기능에 쓰면 문제를 감추는 쪽이 됩니다. 제 경우가 정확히 그랬습니다.

connectedAndroidTest로 돌리면 권한이 자꾸 초기화되는데요?

connectedAndroidTest는 앱을 설치하면서 권한 상태를 지웁니다. 저는 설치 → pm grantam instrument 순서로 나눠 돌립니다. GrantPermissionRule과 별개로, 테스트 밖에서 미리 필요한 권한을 주고 시작하는 편이 결과가 안정적입니다.

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

카메라 화면에 로그를 심어 두고 며칠 뒤 이런 것을 봤습니다.

camera/bind_failed —
IllegalArgumentException: No available camera can be found lens=0

lens=0CameraSelector.LENS_FACING_FRONT, 즉 전면입니다. 전면 카메라가 없는 기기에서 전환 버튼을 누른 것입니다.

여기까지는 예상 가능한 범위입니다. 진짜 문제는 그 다음이었습니다. 실패한 것은 전면인데, 잘 나오던 후면 미리보기까지 검은 화면이 됩니다. 그리고 그 상태에서 빠져나올 방법이 없습니다. 크래시가 아니라서 아무 안내도 없고, 사용자는 이유 없는 검은 화면만 봅니다.

왜 후면까지 죽는가 — 순서 문제다

문제가 된 코드는 흔한 모양이었습니다.

// ❌ 떼고 나서 붙이는데, 붙이기가 실패하면 아무것도 안 남는다
LaunchedEffect(lensFacing) {
    val provider = ProcessCameraProvider.getInstance(context).get()
    val selector = CameraSelector.Builder().requireLensFacing(lensFacing).build()

    provider.unbindAll()
    provider.bindToLifecycle(lifecycleOwner, selector, preview, imageCapture)
}

unbindAll()이 먼저입니다. 이 줄을 지나는 순간 기존에 잘 붙어 있던 후면 바인딩이 사라집니다. 그리고 다음 줄에서 없는 렌즈로 붙이려다 예외가 납니다. 남는 상태는 "아무것도 안 붙은 상태"입니다.

전환 한 번의 실패가 카메라 기능 전체를 끕니다.

게다가 재시도가 없다

LaunchedEffect(lensFacing)키가 바뀔 때만 다시 돕니다. 실패해도 lensFacing은 여전히 전면 값 그대로이므로, 이 블록은 다시 실행되지 않습니다. 복구 경로가 아예 없는 것입니다. 화면을 나갔다 들어와도 상태가 남아 있으면 그대로입니다.

같은 자리에 하나 더 있었습니다
ProcessCameraProvider.getInstance(context).get()try 블록 에 있었습니다. 이 호출은 CameraUnavailableException: Available cameras: 0을 던질 수 있는데, 카메라가 없는 기기뿐 아니라 다른 앱이 카메라를 잠깐 붙들고 있는 일시적 상황에서도 던집니다. 밖에 있으면 그대로 크래시입니다. 미리보기가 안 뜨더라도 앱은 살아 있어야 사용자가 갤러리와 설정이라도 쓸 수 있습니다.

고친 방식 — 떼기 전에 확인하고, 실패하면 되돌린다

세 겹으로 얽힌 문제라 세 군데를 고쳤습니다.

① 있는지 먼저 묻는다 — unbindAll()을 지나기 전에

val provider = ProcessCameraProvider.getInstance(context).get()

// 두 렌즈가 다 있을 때만 전환 버튼을 띄운다
canSwitch = provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) &&
            provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)

val selector = CameraSelector.Builder().requireLensFacing(lensFacing).build()

// ⚠️없는 렌즈라면 unbindAll() 을 지나기 전에 돌아선다
if (!provider.hasCamera(selector)) {
    if (lensFacing != boundLens) {
        lensFacing = boundLens        // 전환 실패 — 되던 렌즈로 되돌린다
    } else if (!flippedOnce) {
        flippedOnce = true            // 처음 고른 렌즈가 없다 — 반대쪽을 한 번만
        lensFacing = otherLens(lensFacing)
    }
    return@LaunchedEffect
}

hasCamera(selector)가 핵심입니다. 떼기 전에 물어보면 잘 붙어 있던 것을 잃지 않습니다.

② 실패해도 되돌아갈 자리를 기억한다

// 마지막으로 **실제 바인딩에 성공한** 렌즈. 전환이 실패했을 때 되돌아갈 자리다.
var boundLens by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) }

// ... 바인딩 성공 직후에만 갱신한다
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, selector, preview, imageCapture)
boundLens = lensFacing

lensFacing(가고 싶은 곳)과 boundLens(실제로 성공한 곳)를 따로 둡니다. 하나로 합쳐 두면 "되돌릴 자리"라는 정보 자체가 없습니다.

그리고 catch에서도 되돌립니다. 되돌리면 lensFacing이 바뀌므로 LaunchedEffect가 다시 돌아 미리보기가 살아납니다.

} catch (e: Exception) {
    // 기록만 하고 끝내면 재시도 자체가 없다 — 검은 화면이 그대로 남는다
    logIssue("camera", "bind_failed", "${e::class.simpleName}: ${e.message} lens=$lensFacing")
    if (lensFacing != boundLens) lensFacing = boundLens
}
이 되돌림은 전면이 없는 기기만을 위한 것이 아닙니다. 다른 앱이 카메라를 잠깐 점유해서 바인딩이 실패한 실사용자 상황도 같은 길로 복구됩니다. 자동 검사가 찾아 준 건 전면 없는 기기였지만, 실제로 막힌 구멍은 그쪽이 더 큽니다.

③ 없는 렌즈로 가는 버튼을 아예 안 띄운다

if (canSwitch) {
    IconButton(onClick = { lensFacing = otherLens(lensFacing) }) { /* ... */ }
}
버튼을 감출 때는 같은 크기 Spacer로 대신하세요.
그냥 지우면 Arrangement.SpaceBetween이 셔터 버튼을 오른쪽으로 밀어 가운데 정렬이 어긋납니다. 레이아웃이 기기마다 달라 보이는 원인이 됩니다.

무한 왕복 빗장

flippedOnce가 왜 필요한지 짚고 갑니다. 처음 고른 렌즈가 없어서 반대쪽으로 넘겼는데 그쪽도 없으면, 다시 반대쪽으로 넘기면서 두 렌즈 사이를 무한히 왕복합니다. 카메라가 아예 없는 기기에서 실제로 그렇게 됩니다. 한 번만 넘겨 보고 멈추게 해야 합니다.

검증 — 실기기로는 이 버그를 못 잡는다

여기가 제가 얻은 가장 큰 교훈입니다. 제 테스트 폰은 전면과 후면이 다 있어서 문제의 분기에 아예 들어가지 않습니다. 몇 번을 눌러 봐도 정상입니다.

조건을 만들어야 합니다. 에뮬레이터를 전면 카메라 없이 띄웁니다.

emulator -avd Pixel_5_API_30 -camera-front none

제대로 걸렸는지 확인합니다.

adb shell dumpsys media.camera | findstr /C:"Number of camera devices" /C:"Facing"
Number of camera devices: 1
Facing: Back
AVD 설정(hw.camera.front)을 고치지 말고 실행 플래그로 주세요. 설정을 건드리면 그 AVD를 쓰는 다른 작업까지 영향을 받습니다. 플래그는 이번 실행에만 적용됩니다.

음성 대조군을 반드시 확인하세요

테스트를 짜고 초록불이 떴다고 끝내면 안 됩니다. 수정 전 코드를 같은 에뮬레이터에 올려서 빨간불이 뜨는지 봐야 합니다. 저는 이렇게 확인했습니다.

전면=false 후면=true 인데 전환 버튼 1개  → 실패

이게 나와야 그 테스트가 실제로 이 버그를 잡는다는 게 증명됩니다. 대조군 없이 통과만 보면, 조건에 못 닿아서 초록불인 경우와 구분되지 않습니다.

스킵을 통과로 읽지 마세요.
"전면 없는 기기에서 전환 버튼이 숨는가"를 assumeTrue로 감싸 두면, 전면 있는 기기에서는 스킵됩니다. 결과 요약에는 실패가 0으로 나오지만 그 경로를 아예 안 밟은 것입니다.

정리

  • unbindAll()돌아올 수 없는 지점입니다. 그 앞에서 hasCamera()로 확인하세요.
  • "가고 싶은 렌즈"와 "실제로 성공한 렌즈"를 따로 들고, 실패하면 되돌려 LaunchedEffect를 다시 돌립니다.
  • getInstance().get()try 안에 넣으세요. 일시적 점유에도 던집니다.
  • 실패를 기록하세요. 크래시가 아닌 고장은 로그를 안 심으면 영영 모릅니다.
  • 없는 렌즈로 가는 버튼 자체를 감추고, 무한 왕복 빗장을 겁니다.
  • 검증은 -camera-front none 에뮬레이터 + 음성 대조군으로 합니다.

자주 묻는 것

전면 없는 기기가 요즘 있나요?

제 실사용자 중에는 없었습니다. 이 로그를 낸 것도 자동 검사 기기와 에뮬레이터였습니다. 그래도 고친 이유는 같은 코드 경로를 실사용자 상황이 공유하기 때문입니다. 다른 앱이 카메라를 점유한 순간 전환을 누르면 똑같이 검은 화면에 갇힙니다. 지표가 0인 것과 코드가 맞는 것은 다릅니다.

DEFAULT_FRONT_CAMERA로 바인딩하면 알아서 폴백되지 않나요?

requireLensFacing은 이름 그대로 필수 조건이라 없으면 예외입니다. 자동 폴백을 기대하고 짜면 이 글의 상황이 그대로 재현됩니다.

화면에 안내를 띄우는 게 낫지 않나요?

둘 다 하는 게 맞습니다. 다만 먼저 할 것은 미리보기를 살리는 것입니다. "전면 카메라가 없습니다"만 띄우고 검은 화면을 그대로 두면 사용자는 여전히 아무것도 못 합니다.

728x90
반응형
LIST
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

+ Recent posts