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

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

윈도우 업데이트가 설치 도중에 실패하고 계속 같은 걸 다시 시도하는 상황이 있습니다. 재부팅을 해도, 며칠을 기다려도 똑같은 오류 코드가 반복되죠.

여기서 중요한 건 오류 코드마다 원인이 완전히 다르다는 점입니다. 어떤 건 디스크 공간 문제고, 어떤 건 서비스가 꺼진 것이고, 어떤 건 숨겨진 복구 파티션이 작아서 생깁니다. 코드를 무시하고 아무 해결법이나 따라 하면 시간만 버립니다.

먼저 — 공통 점검 5단계

어떤 코드든 아래 다섯 가지로 해결되는 경우가 많습니다. 순서대로 진행하세요. 전부 되돌릴 수 있는 안전한 작업입니다.

1) 업데이트 문제 해결사

설정 → 시스템 → 문제 해결 → 기타 문제 해결사 → Windows Update 에서 실행합니다. 가장 먼저 해볼 일이고, 실제로 여기서 끝나는 경우가 꽤 있습니다.

2) 디스크 여유 공간 확보

업데이트는 압축을 풀 공간이 필요합니다. C 드라이브 여유 공간이 10GB 미만이면 이것부터 해결하세요. 설정 → 시스템 → 저장 공간에서 임시 파일을 정리하면 됩니다.

3) 시스템 파일 검사

명령 프롬프트를 관리자 권한으로 열고 차례로 실행합니다.

sfc /scannow

DISM /Online /Cleanup-Image /RestoreHealth

sfc가 손상된 시스템 파일을 고치고, DISM은 윈도우 이미지 자체를 복구합니다. sfc가 "복구할 수 없다"고 하면 DISM을 먼저 돌린 뒤 sfc를 다시 실행하세요. 각각 10분 이상 걸릴 수 있습니다.

4) 업데이트 캐시 초기화

받다 만 파일이 꼬여 있으면 계속 같은 지점에서 실패합니다. 캐시 폴더를 비우면 처음부터 다시 받습니다.

net stop wuauserv
net stop bits

ren C:\Windows\SoftwareDistribution SoftwareDistribution.old
ren C:\Windows\System32\catroot2 catroot2.old

net start wuauserv
net start bits
폴더를 지우지 않고 이름만 바꾸는 이유는 문제가 생겼을 때 되돌리기 위해서입니다. 업데이트가 정상화되면 .old 폴더는 지워도 됩니다.

5) 관련 서비스 확인

services.msc를 실행해 아래 서비스가 켜져 있는지 봅니다.

서비스시작 유형
Windows Update수동 또는 자동
Background Intelligent Transfer Service (BITS)수동
Cryptographic Services자동

오류 코드별 대처

0x80070643 — 복구 파티션 공간 부족

원래는 설치 실패 전반에 쓰이는 코드지만, 최근 이 코드로 보안 업데이트만 반복 실패한다면 원인이 꽤 특정됩니다. 윈도우 설치 시 만들어진 숨겨진 복구 파티션(WinRE)이 너무 작아서 업데이트를 적용하지 못하는 경우입니다.

공통 5단계로는 해결되지 않습니다. 마이크로소프트가 안내하는 정식 해결법은 주 파티션을 줄여 복구 파티션 공간을 늘리는 작업인데, 이건 디스크 구조를 직접 건드립니다.

주의 — 파티션 조정은 되돌리기 어렵고, 잘못하면 부팅이 안 될 수 있습니다. 진행 전에 중요한 데이터를 반드시 백업하세요. 자신이 없다면 아래 대안을 권합니다.
  • 해당 업데이트만 일시적으로 건너뛰고 다음 누적 업데이트를 기다린다 (다음 업데이트에 포함되어 해소되는 경우가 있습니다)
  • PC를 맡길 수 있는 상황이라면 서비스센터에 의뢰한다

현재 복구 파티션 상태만 확인하고 싶다면, 관리자 명령 프롬프트에서 아래로 볼 수 있습니다. 조회만 하는 명령이라 안전합니다.

reagentc /info

여기서 Windows RE 상태가 Enabled인지, 어느 파티션에 있는지 확인할 수 있습니다.

0x800f0922 — 공간 부족 또는 연결 차단

원인이 세 갈래입니다. 위에서부터 확인하세요.

원인확인·조치
시스템 예약 파티션 공간 부족공통 2단계(디스크 정리) 후 재시도
VPN 연결VPN을 완전히 끊고 재시도 — 업데이트 서버 접속이 막힙니다
백신·방화벽 차단타사 백신을 일시 중지하고 재시도

회사 노트북처럼 VPN을 상시 연결해 두는 환경에서 특히 자주 나옵니다. 의외로 이것만 꺼도 해결되는 경우가 많습니다.

0x80070422 — 업데이트 서비스가 꺼져 있음

가장 단순한 코드입니다. Windows Update 서비스가 사용 안 함으로 설정된 상태입니다. 업데이트를 미루려고 서비스를 꺼둔 뒤 잊어버린 경우가 대부분입니다.

services.msc → Windows Update → 시작 유형을 수동으로 바꾸고 시작하면 됩니다.

0x8007000d / 0x80073712 — 파일 손상

업데이트 파일이 깨졌다는 뜻입니다. 공통 3단계(sfc + DISM)와 4단계(캐시 초기화)를 둘 다 수행한 뒤 재시도하세요.

0x80240034 / 0x8024402c — 다운로드 실패

네트워크 쪽 문제입니다. DNS를 바꿔보거나, 회사·학교 네트워크라면 업데이트 서버가 차단됐을 수 있으니 다른 네트워크에서 시도해 보세요.

그래도 안 될 때 — 수동 설치

특정 업데이트 하나만 계속 실패한다면, 그 업데이트를 직접 받아 설치하는 방법이 있습니다.

  1. 설정 → Windows Update → 업데이트 기록에서 실패한 항목의 KB 번호를 확인 (예: KB5034441)
  2. Microsoft 업데이트 카탈로그에서 그 KB 번호로 검색
  3. 내 시스템에 맞는 버전(대부분 x64) 파일을 받아 실행

수동 설치도 같은 오류로 실패한다면, 원인이 업데이트 전달 과정이 아니라 시스템 자체에 있다는 신호입니다. 그때는 공통 3단계로 돌아가세요.

점검 순서 요약

순서할 일위험도
1오류 코드 확인 (업데이트 기록)없음
2문제 해결사 실행없음
3디스크 여유 공간 10GB 이상 확보없음
4sfc + DISM낮음
5업데이트 캐시 초기화낮음 (이름 변경이라 복구 가능)
6코드별 개별 조치코드마다 다름
7파티션 조정높음 — 백업 필수

자주 묻는 질문

Q. 업데이트를 아예 안 하면 안 되나요?

기능 업데이트는 미뤄도 되지만 보안 업데이트는 미루지 않는 편이 좋습니다. 다만 특정 업데이트 하나가 계속 실패한다면, 다음 누적 업데이트에 포함되어 자연히 해결되기도 합니다. 무리해서 파티션을 건드리기 전에 한 달 정도 기다려 보는 것도 방법입니다.

Q. sfc /scannow가 "복구할 수 없는 파일이 있다"고 합니다.

DISM /Online /Cleanup-Image /RestoreHealth를 먼저 실행해 윈도우 이미지를 복구한 다음, sfc /scannow를 다시 돌리세요. 순서를 바꾸면 결과가 달라집니다.

Q. SoftwareDistribution 폴더를 지워도 되나요?

업데이트가 정상화된 뒤라면 .old로 이름을 바꿔둔 폴더는 지워도 됩니다. 다만 처음부터 삭제하지 말고 이름 변경으로 시작하세요. 문제가 생겨도 되돌릴 수 있습니다.

Q. 오류 코드가 매번 다르게 나옵니다.

디스크 불량이나 메모리 문제일 수 있습니다. chkdsk C: /f와 윈도우 메모리 진단 도구로 하드웨어 쪽을 먼저 확인해 보세요.

정리

윈도우 업데이트 오류는 코드를 먼저 확인하는 것이 절반입니다. 공통 5단계로 상당수가 해결되고, 남는 건 코드별 원인이 뚜렷합니다. 0x800f0922의 VPN이나 0x80070422의 꺼진 서비스처럼 허무할 만큼 단순한 원인인 경우도 많으니, 파티션을 건드리는 위험한 작업은 항상 마지막에 두세요.

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

웹뷰로 띄운 페이지에서 사진 첨부 버튼을 눌렀는데 아무 일도 일어나지 않습니다. 모바일 브라우저에서는 갤러리가 잘 열리는데 앱 안에서만 먹통입니다. 서류 제출, 프로필 사진, 리뷰 사진 첨부처럼 <input type="file">을 쓰는 화면에서 반드시 만나는 문제입니다.

증상은 보통 두 가지로 나뉩니다.

증상원인
버튼을 눌러도 처음부터 아무 반응 없음onShowFileChooser 미구현
한 번은 되는데 두 번째부터 안 열림콜백을 비워주지 않음 (아래 함정 1)

원인 — 웹뷰에는 파일 선택 화면이 없다

웹 페이지에서 파일 선택 요소를 누르면 안드로이드는 WebChromeClient.onShowFileChooser()를 호출합니다. 브라우저 앱은 이걸 내부에서 알아서 처리하지만, 우리가 만든 웹뷰는 이 함수를 구현해 주지 않으면 아무것도 하지 않습니다. 눌러도 반응이 없는 이유입니다.

기본 구현

결과를 받을 런처를 먼저 만들고, onShowFileChooser에서 파일 선택 화면을 띄웁니다.

private var filePathCallback: ValueCallback<Array<Uri>>? = null

private val fileChooserLauncher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result ->
    // 공식 헬퍼가 단일·복수 선택을 알아서 파싱해 준다
    val uris = WebChromeClient.FileChooserParams
        .parseResult(result.resultCode, result.data)
    filePathCallback?.onReceiveValue(uris)
    filePathCallback = null          // ★ 반드시 비운다
}
webView.webChromeClient = object : WebChromeClient() {

    override fun onShowFileChooser(
        webView: WebView?,
        callback: ValueCallback<Array<Uri>>?,
        params: FileChooserParams?
    ): Boolean {
        // 이전 요청이 남아 있으면 정리하고 시작
        filePathCallback?.onReceiveValue(null)
        filePathCallback = callback

        return try {
            fileChooserLauncher.launch(params?.createIntent())
            true                     // 우리가 처리했다
        } catch (e: ActivityNotFoundException) {
            filePathCallback = null
            false                    // 처리 못 했다
        }
    }
}
FileChooserParams.createIntent()는 웹 페이지의 accept 속성(예: accept="image/*")을 반영한 인텐트를 만들어 줍니다. 직접 인텐트를 조립하기 전에 이걸 먼저 써 보세요.

가장 많이 겪는 함정

함정 1 — 한 번은 되는데 두 번째부터 안 열린다

압도적으로 흔한 증상이고, 원인은 하나입니다. 사용자가 파일 선택을 취소했거나 권한을 거부했을 때 콜백을 비워주지 않은 것입니다.

// ❌ 취소했을 때 아무것도 안 하면
//    안드로이드는 "아직 파일 선택 중"이라고 판단한다
//    → 다음부터 onShowFileChooser 가 아예 호출되지 않는다

// ✅ 어떤 경로로 끝나든 반드시 값을 돌려준다
filePathCallback?.onReceiveValue(null)
filePathCallback = null

취소, 권한 거부, 예외 발생 — 모든 종료 경로에서 onReceiveValue()를 호출해야 합니다. 위 기본 구현에서 런처 콜백과 onShowFileChooser 진입부 양쪽에 정리 코드를 넣은 이유입니다.

함정 2 — 카메라로 바로 찍기가 안 된다

createIntent()는 보통 갤러리·파일 선택만 띄웁니다. "사진 촬영"도 같이 주려면 카메라 인텐트를 합쳐야 합니다.

val galleryIntent = params?.createIntent()

val photoFile = File(cacheDir, "capture_${System.currentTimeMillis()}.jpg")
val photoUri = FileProvider.getUriForFile(
    this, "$packageName.fileprovider", photoFile)   // ★ FileProvider 필수
cameraImageUri = photoUri

val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
    putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}

val chooser = Intent(Intent.ACTION_CHOOSER).apply {
    putExtra(Intent.EXTRA_INTENT, galleryIntent)
    putExtra(Intent.EXTRA_TITLE, "사진 첨부")
    putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent))
}
fileChooserLauncher.launch(chooser)
카메라 결과는 FileChooserParams.parseResult()로 잡히지 않습니다. 촬영으로 끝났을 때는 result.datanull이므로, 미리 저장해 둔 cameraImageUri를 대신 넘겨야 합니다.
val uris = if (result.data == null && cameraImageUri != null) {
    arrayOf(cameraImageUri!!)                 // 카메라로 찍은 경우
} else {
    FileChooserParams.parseResult(result.resultCode, result.data)
}

함정 3 — 카메라 인텐트에서 앱이 죽는다 (FileUriExposedException)

Uri.fromFile()로 만든 file:// 주소를 다른 앱에 넘기면 FileUriExposedException이 발생합니다. 반드시 FileProvider를 거쳐야 합니다.

<!-- AndroidManifest.xml -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

함정 4 — 안드로이드 13부터 권한 이름이 바뀌었다

READ_EXTERNAL_STORAGE만 선언해 두면 안드로이드 13(API 33) 이상에서 동작하지 않습니다.

버전필요 권한
API 32 이하READ_EXTERNAL_STORAGE
API 33 이상READ_MEDIA_IMAGES / READ_MEDIA_VIDEO
API 34 이상위에 더해 READ_MEDIA_VISUAL_USER_SELECTED (일부만 선택 허용)
더 나은 방법: 사진만 고르면 되는 화면이라면 권한 자체가 필요 없습니다. 시스템 사진 선택 도구(Photo Picker)를 쓰면 저장소 권한 없이 사용자가 고른 이미지만 받을 수 있고, 플레이 정책 심사에서도 유리합니다. 카메라 촬영이 필요할 때만 CAMERA 권한을 요청하세요.

함정 5 — 자바스크립트나 파일 접근 설정 누락

webView.settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true
    allowFileAccess = true
    javaScriptCanOpenWindowsAutomatically = true
    setSupportMultipleWindows(true)   // 첨부창을 새 창으로 여는 페이지 대응
}

점검 순서

확인내용
1onShowFileChooser에 로그를 넣고 호출 자체가 되는지 확인
2호출이 되는데 두 번째부터 안 불린다 → 함정 1 (콜백 미정리)
3WebChromeClient를 설정했는지 확인 (WebViewClient와 헷갈리기 쉽다)
4targetSdk 33 이상이면 권한 이름 확인 (함정 4)
5카메라가 안 되면 FileProvider 설정 확인 (함정 3)

자주 묻는 질문

Q. WebViewClient에 넣었는데 왜 호출이 안 되나요?

onShowFileChooserWebChromeClient의 함수입니다. 이름이 비슷해 자주 혼동되는 부분인데, webChromeClient 쪽에 설정해야 합니다.

Q. 처음 한 번만 되고 이후로 먹통입니다.

함정 1입니다. 사용자가 취소했을 때 onReceiveValue(null)을 호출하지 않으면 이전 요청이 끝나지 않은 상태로 남아 다음 호출이 무시됩니다.

Q. 여러 장을 한 번에 올리고 싶습니다.

웹 페이지 쪽에 <input type="file" multiple>이 있으면 createIntent()가 복수 선택 인텐트를 만들고, parseResult()가 배열로 돌려줍니다. 앱에서 따로 처리할 필요는 없습니다.

Q. 파일은 선택되는데 서버에 안 올라갑니다.

이 경우는 웹뷰가 아니라 웹 페이지 쪽 문제일 가능성이 큽니다. 같은 페이지를 크롬 브라우저에서 열어 업로드가 되는지 먼저 확인해 보세요. 브라우저에서도 안 되면 서버·프론트 쪽을 봐야 합니다.

정리

웹뷰 파일 업로드는 onShowFileChooser를 구현하면 대부분 해결됩니다. 문제는 "한 번은 되는데 그 다음부터 안 되는" 상태인데, 이건 거의 항상 콜백을 비워주지 않아서 생깁니다. 어떤 경로로 끝나든 onReceiveValue()를 반드시 호출한다는 원칙만 지키면 재현이 사라집니다.

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

앱 안에 웹뷰로 결제창을 띄웠는데, 카드사 앱으로 넘어가야 할 순간에 이런 화면이 뜹니다.

net::ERR_UNKNOWN_URL_SCHEME

모바일 웹 브라우저에서는 잘 되는데 앱 안 웹뷰에서만 터집니다. 국내 결제(이니시스·토스페이먼츠·KG·나이스), 본인인증(NICE·KCB), 카카오 로그인 연동에서 거의 예외 없이 만나는 오류입니다.

원인 — 웹뷰는 http/https만 안다

결제창이 카드사 앱을 부를 때 실제로 넘기는 주소는 이렇게 생겼습니다.

intent://pay?orderId=1234#Intent;scheme=kftc-bankpay;
  package=com.kftc.bankpay.android;
  S.browser_fallback_url=https%3A%2F%2F...;end;

// 또는 카드사 전용 스킴
ispmobile://...
kakaotalk://...
market://details?id=...

브라우저(크롬)는 이런 주소를 만나면 스스로 해당 앱을 실행해 줍니다. 반면 WebView는 그런 기능이 없습니다. http와 https 외의 스킴은 어떻게 처리할지 앱이 직접 알려주지 않으면 그냥 실패합니다. 그 실패가 ERR_UNKNOWN_URL_SCHEME입니다.

주소 형태브라우저앱 안 WebView
https://...연다연다
intent://...앱 실행해 줌실패 → ERR_UNKNOWN_URL_SCHEME
ispmobile:// 등 앱 스킴앱 실행해 줌실패

기본 해결 — shouldOverrideUrlLoading에서 가로채기

웹뷰가 주소를 열기 직전에 끼어들어, http/https가 아니면 우리가 직접 앱을 실행해 주면 됩니다.

webView.webViewClient = object : WebViewClient() {

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

        // http/https 는 웹뷰가 그대로 처리하게 둔다
        if (url.startsWith("http://") || url.startsWith("https://")) return false

        return handleAppScheme(view, url)
    }
}

핵심은 handleAppScheme()입니다. intent://는 파싱이 필요하고, 그 외 앱 스킴은 바로 열면 됩니다.

private fun handleAppScheme(view: WebView, url: String): Boolean {
    val context = view.context

    // 1) intent:// 가 아닌 일반 앱 스킴 (ispmobile:// 등)
    if (!url.startsWith("intent://")) {
        return try {
            context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
            true
        } catch (e: ActivityNotFoundException) {
            false   // 처리 못 하면 웹뷰에 맡긴다
        }
    }

    // 2) intent:// 는 Intent 객체로 파싱
    val intent = try {
        Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
    } catch (e: URISyntaxException) {
        return false
    }

    // 3) 바로 실행하고, 실패하면 그때 대응한다  ★ 이 순서가 중요
    try {
        context.startActivity(intent)
        return true
    } catch (e: ActivityNotFoundException) {
        // 앱이 안 깔려 있는 경우
    }

    // 4) 결제창이 알려준 폴백 주소가 있으면 그리로
    intent.getStringExtra("browser_fallback_url")?.let { fallback ->
        view.loadUrl(fallback)
        return true
    }

    // 5) 없으면 스토어로 유도
    intent.`package`?.let { pkg ->
        try {
            context.startActivity(
                Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$pkg")))
        } catch (e: ActivityNotFoundException) {
            context.startActivity(Intent(Intent.ACTION_VIEW,
                Uri.parse("https://play.google.com/store/apps/details?id=$pkg")))
        }
        return true
    }

    return false
}

인텐트 처리를 넣었는데도 안 될 때

여기까지는 검색하면 흔히 나오는 내용입니다. 그런데도 계속 실패한다는 문의가 끊이지 않는데, 대부분 아래 다섯 가지 중 하나입니다.

함정 1 — resolveActivity()로 먼저 확인하는 코드 (Android 11 이상)

오래된 예제에는 이런 패턴이 많습니다.

// ❌ Android 11(API 30)부터 깨지는 코드
if (intent.resolveActivity(packageManager) != null) {
    startActivity(intent)
} else {
    // 앱이 없다고 판단 → 실제로는 깔려 있는데도 여기로 빠진다
}

Android 11부터 패키지 가시성(package visibility) 정책이 생겼습니다. resolveActivity()queryIntentActivities()는 이 제약을 받기 때문에, <queries> 선언이 없으면 실제로 설치된 앱도 "없다"고 답합니다. 카드사 앱이 분명히 깔려 있는데 스토어로 보내버리는 증상이 이것입니다.

공식 권장 방식은 사전 확인을 하지 않는 것입니다. startActivity() 자체는 패키지 가시성 제약을 받지 않으므로, 그냥 실행하고 ActivityNotFoundException을 잡는 편이 더 정확하고 빠릅니다. 위 코드가 3번에서 바로 실행하는 이유입니다.

<queries> 선언이 필요한 경우는 따로 있습니다. "이 카드사 앱이 깔려 있는지"를 화면에 표시해야 해서 조회가 꼭 필요하다면, 그때는 매니페스트에 선언해야 합니다.

<!-- AndroidManifest.xml — 조회가 정말 필요할 때만 -->
<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="ispmobile" />
    </intent>
</queries>

함정 2 — 구버전 기기에서만 실패

shouldOverrideUrlLoading은 시그니처가 두 개입니다. WebResourceRequest를 받는 쪽은 API 24 이상에서만 호출됩니다. minSdk가 그보다 낮다면 구형 기기에서는 우리 코드가 아예 실행되지 않습니다.

// API 24 미만도 지원한다면 둘 다 오버라이드
@Deprecated("API 24 미만 대응용")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
    if (url.startsWith("http://") || url.startsWith("https://")) return false
    return handleAppScheme(view, url)
}

함정 3 — 결제창이 새 창으로 뜨는 경우

PG사 결제창은 target="_blank"window.open()으로 새 창을 여는 구조가 많습니다. 이때는 shouldOverrideUrlLoading호출되지 않고 아무 반응이 없습니다. 멀티 윈도우를 켜고 onCreateWindow를 처리해야 합니다.

webView.settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true
    javaScriptCanOpenWindowsAutomatically = true
    setSupportMultipleWindows(true)      // ★ 새 창 허용
}

함정 4 — 앱 스킴이 intent:// 형태가 아닌 경우

Intent.parseUri()intent:// 형식을 위한 함수입니다. ispmobile://, kakaotalk:// 같은 순수 커스텀 스킴에 그대로 쓰면 원하는 대로 동작하지 않을 수 있습니다. 위 코드에서 1번과 2번을 나눈 이유입니다.

함정 5 — Flutter · React Native는 원인이 다르다

하이브리드 프레임워크는 웹뷰 플러그인 자체의 제약이 원인인 경우가 있습니다. 네이티브 코드를 아무리 고쳐도 해결되지 않으므로 플러그인 쪽부터 확인하세요.
  • Flutter: webview_flutter는 커스텀 스킴 처리에 제약이 있어, flutter_inappwebview로 바꾸고 인텐트 스킴을 등록하는 방식이 널리 쓰입니다. 플러그인 버전에 따라 동작이 달라지므로 버전 이슈를 먼저 확인하는 편이 빠릅니다.
  • React Native: react-native-webviewonShouldStartLoadWithRequest에서 걸러내고, 앱 실행은 Linking.openURL()로 처리합니다.

점검 순서

확인내용
1로그캣에 어떤 주소에서 막혔는지 찍어본다 (intent://인가 커스텀 스킴인가)
2shouldOverrideUrlLoading이 실제로 호출되는지 로그로 확인 — 안 불린다면 함정 3(새 창)
3resolveActivity() 사전 체크 코드가 있는지 확인 — 있으면 제거 (함정 1)
4카드사 앱을 지운 상태에서도 테스트 — 폴백과 스토어 이동이 동작하는지
5실기기에서 테스트 — 에뮬레이터에는 카드사 앱이 없어 항상 폴백으로 빠진다

자주 묻는 질문

Q. 모바일 웹에서는 되는데 왜 앱에서만 안 되나요?

브라우저는 intent://를 만나면 스스로 앱을 실행해 주지만, WebView에는 그 기능이 없습니다. 앱이 직접 처리해 주지 않으면 실패합니다.

Q. resolveActivity()를 빼도 정말 괜찮나요?

괜찮습니다. startActivity()는 패키지 가시성 제약을 받지 않아 앱이 설치돼 있으면 정상 실행되고, 없으면 ActivityNotFoundException이 발생합니다. 그 예외를 잡아 폴백을 태우는 것이 Android 공식 문서의 권장 방식입니다.

Q. 에뮬레이터에서 자꾸 스토어로 넘어갑니다.

정상 동작입니다. 에뮬레이터에는 카드사 앱이 설치돼 있지 않아 폴백 경로를 타는 것입니다. 결제 연동 테스트는 실기기로 하세요.

Q. 모든 스킴을 다 열어줘도 보안상 괜찮나요?

주의가 필요합니다. 외부에서 주입된 주소로 임의의 앱을 실행할 수 있으면 위험합니다. 결제·인증처럼 도메인이 정해진 흐름이라면, 신뢰하는 호스트에서 출발한 요청인지 확인한 뒤 처리하는 편이 안전합니다.

정리

ERR_UNKNOWN_URL_SCHEME은 웹뷰가 http/https 외의 주소를 처리할 방법을 모르기 때문에 나는 오류입니다. shouldOverrideUrlLoading에서 가로채 Intent.parseUri()로 앱을 실행하고, 실패하면 browser_fallback_url과 스토어로 이어지는 경로를 만들어 두면 해결됩니다.

그래도 안 된다면 사전 확인 코드(resolveActivity)와 새 창 설정을 먼저 의심하세요. 코드를 제대로 넣고도 못 고치는 사례의 대부분이 이 둘입니다.

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

게임을 하나 켰더니 작업하던 VSCode의 터미널이 통째로 죽고, 클로드 코드(Claude Code) 확장도 같이 나가떨어졌습니다. 그런데 이상하게 브라우저로 접속한 AI 서비스는 멀쩡했습니다. 램은 34GB, 게임 하나 켠 것뿐인데 말이죠.

처음엔 당연히 메모리 부족을 의심했습니다. 그다음엔 백신을 의심했고요. 둘 다 틀렸습니다. 진짜 범인은 게임의 안티치트(anti-cheat)였습니다.

이 글은 그 진단 과정을 실측 로그 그대로 정리한 것입니다. 같은 증상이라면 명령어 몇 줄로 5분 안에 확인할 수 있습니다.

증상 — 이런 조합이면 이 글이 맞습니다

  • 게임을 실행하면 VSCode 터미널이 그냥 사라진다 (VSCode 자체는 살아 있음)
  • 클로드 코드, 커서(Cursor) 같은 CLI·확장이 죽는다
  • 터미널에서 직접 실행해도 Bun has crashed 패닉이 뜬다
  • 그런데 브라우저로 쓰는 웹 서비스는 정상
  • 게임을 끄면 다시 멀쩡해진다

실제로 터미널에 찍힌 내용입니다.

============================================================
Bun v1.4.0 (eb835313a) Windows x64 (baseline)
Windows v10.19045
CPU: sse42 avx avx2
Args: "claude"
...
Elapsed: 1003ms | User: 515ms | Sys: 421ms
RSS: 0.29 GB | Peak: 0.29 GB | Commit: 0.27 GB | Faults: 89945 | Machine: 34.30 GB

panic(thread 24128): Segmentation fault at address 0x3C
oh no: Bun has crashed. This indicates a bug in Bun, not your code.

VSCode 확장 쪽에서는 이렇게 나옵니다.

Error: Claude Code process exited with code 3. stderr: ...
Args: "...\anthropic.claude-code-...\resources\native-binary\claude.exe" ...
panic(thread 19544): Segmentation fault at address 0x3C
oh no: Bun has crashed.

1단계 — 메모리 부족부터 지웁니다

가장 먼저 걷어내야 할 오해입니다. 크래시 로그의 마지막 줄을 보세요.

RSS: 0.29 GB | Peak: 0.29 GB | Machine: 34.30 GB

34.3GB 중 0.29GB를 쓰다 죽었습니다. 메모리 부족이면 OutOfMemory나 할당 실패 메시지가 나오지, Segmentation fault가 나오지 않습니다. 세그멘테이션 폴트는 접근하면 안 되는 주소를 건드렸을 때 나는 오류입니다.

게다가 결정적인 단서가 하나 더 있습니다. 필자의 경우 터미널에서 죽은 것과 VSCode 확장에서 죽은 것의 주소가 똑같이 0x3C였습니다. 주소가 매번 같다는 건 무작위 메모리 고갈이 아니라 재현성 있는 단일 원인이라는 뜻입니다.

0x3C가 무슨 의미인가
아주 낮은 주소값입니다. 보통 널 포인터에 구조체 오프셋을 더한 위치를 읽으려다 터진 흔적입니다. 즉 "있어야 할 객체가 없었다"는 신호입니다. 램 용량과는 무관합니다.

2단계 — 백신도 지웁니다

두 번째 용의자는 백신입니다. 윈도우에서 개발 도구가 막히는 흔한 원인이니까요. 확인 명령은 이렇습니다.

# PowerShell — 윈도우 디펜더의 '제어된 폴더 액세스' 상태와 예외 목록
Get-MpPreference | Select-Object EnableControlledFolderAccess, ExclusionPath, ExclusionProcess

# 설치된 백신 목록
Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct |
  Select-Object displayName, productState

필자 환경은 EnableControlledFolderAccess : 0(꺼짐)에 예외 목록도 비어 있었습니다. 백신은 상시 실행 중인데 평소엔 아무 문제가 없다가 게임 켤 때만 죽는다는 점에서 이미 논리적으로 걸러집니다. 백신이 범인이면 게임과 무관하게 항상 막혀야 하니까요.

진단의 기본 원칙
"항상 켜져 있는 것"은 "가끔 나는 증상"의 범인이 되기 어렵습니다. 증상과 함께 켜지고 함께 꺼지는 것을 찾아야 합니다.

3단계 — 게임과 함께 켜지는 것을 찾습니다

여기서 범인이 나옵니다. 국내 온라인 게임 상당수는 nProtect GameGuard라는 안티치트를 씁니다. 이건 일반 프로그램이 아니라 커널 드라이버로 올라갑니다.

# 안티치트 서비스가 등록돼 있는지 확인
Get-CimInstance Win32_Service |
  Where-Object { $_.Name -match 'npgg|GameMon|nProtect|xhunter|XIGNCODE|EasyAntiCheat|BEService|vgk' } |
  Select-Object Name, State, StartMode, PathName | Format-List

필자 환경의 출력입니다.

Name      : npggsvc
State     : Stopped
StartMode : Manual
PathName  : C:\Windows\system32\GameMon.des -service

핵심은 StartMode : Manual입니다. 평소엔 꺼져 있다가 게임을 실행하는 순간에만 커널 드라이버가 올라옵니다. 증상이 나타나는 타이밍과 정확히 일치하죠.

게임 폴더에서 확정 짓기

게임 설치 폴더에 안티치트 모듈이 있는지, 그리고 그 파일들이 마지막으로 갱신된 시각이 언제인지 봅니다. 이게 진짜 증거입니다.

# 게임 설치 경로를 자기 것으로 바꿔서 실행
Get-ChildItem 'D:\게임폴더' -Recurse -Depth 2 |
  Where-Object { $_.Name -match 'GameGuard|GameMon|npgg|xigncode|EasyAntiCheat' } |
  Sort-Object LastWriteTime -Descending |
  Select-Object -First 10 FullName, LastWriteTime

필자 환경에서는 GameGuard 폴더 안의 로그 파일들(npgm.erl, npgl.erl, npsc64.erl)이 13:54:41부터 13:59:56까지 계속 갱신되고 있었습니다. 게임이 돌던 구간이죠.

그리고 클로드 코드 크래시 로그에 찍힌 마지막 타임스탬프가 2026-08-10T04:51:53Z, 즉 한국시간 13:51:53이었습니다. 프로세스 총 수명은 Elapsed: 261063ms(약 4분 21초). 안티치트가 활동하던 바로 그 구간에서 죽었습니다.

시각(KST)일어난 일
13:47경클로드 코드 프로세스 시작 (Elapsed 역산)
13:51:53크래시 로그의 마지막 정상 기록
13:54:41GameGuard 모듈 업데이트 파일 갱신 = 게임 기동
13:54 - 13:59안티치트 로그 계속 기록 중 → 이 사이에 세그폴트

수명이 다른 두 크래시 — 여기서 확신했습니다

앞에 붙인 두 로그를 나란히 놓고 보면 재미있는 차이가 보입니다. 같은 주소 0x3C로 죽었는데 프로세스가 살아 있던 시간이 전혀 다릅니다.

실행 경로Elapsed상황
VSCode 확장261,063ms (4분 21초)게임을 켜기 전부터 떠 있었고, 게임이 올라온 뒤 사망
터미널에서 직접 실행1,003ms (1초)게임이 이미 켜진 상태에서 새로 실행 → 즉사

이 대비가 핵심입니다. 안티치트가 올라오기 전에 시작한 프로세스는 한동안 멀쩡히 돌다가 드라이버가 개입한 순간 죽었고, 안티치트가 이미 자리잡은 뒤에 실행한 프로세스는 초기화 단계에서 1초 만에 죽었습니다.

메모리가 서서히 새는 문제였다면 두 번째가 더 오래 버텨야 정상입니다. 반대가 나왔죠. "게임이 켜져 있는 동안에는 몇 번을 새로 띄워도 즉사한다"는 게 확인된 셈이고, 게임을 끄자 아무 조치 없이 원래대로 돌아왔습니다.

왜 하필 이 조합에서 터지나

두 가지가 정면으로 부딪힙니다.

안티치트가 하는 일

GameGuard 같은 커널 안티치트는 치팅 프로그램이 게임 메모리를 조작하는 걸 막기 위해, 시스템 전역의 메모리 보호 변경과 스레드 생성 API를 후킹(가로채기)합니다. NtProtectVirtualMemory, NtCreateThreadEx 같은 것들이죠. 게임 프로세스만 감시하는 게 아니라 시스템 전체를 봅니다. 이게 문제의 출발점입니다.

Bun이 하는 일

클로드 코드의 네이티브 실행 파일은 Bun이라는 자바스크립트 런타임으로 만들어져 있습니다. Bun은 내부적으로 JavaScriptCore의 JIT 컴파일러를 씁니다. JIT은 실행 중에 기계어를 만들어 메모리에 얹고 그 메모리를 실행 가능 상태로 바꾸는 동작을 계속 반복합니다.

그런데 그 "메모리를 실행 가능하게 바꾸는" 동작이야말로 치팅 프로그램의 대표적 행동 패턴과 똑같이 생겼습니다. 안티치트가 이걸 가로채서 다른 값을 돌려주거나 차단하면, Bun 입장에서는 있어야 할 포인터가 널이 됩니다. 그 널 포인터에 오프셋 0x3C를 더해 읽는 순간 세그멘테이션 폴트가 납니다.

이건 Bun의 버그가 아닙니다
크래시 메시지는 "This indicates a bug in Bun"이라고 하지만, 이 경우엔 아닙니다. 외부에서 시스템 API의 동작을 바꿔버린 상태라 어떤 런타임이든 방어할 수 없습니다. Bun 이슈로 신고해도 재현이 안 됩니다.

브라우저는 왜 멀쩡한가

이게 이 가설을 뒷받침하는 가장 강한 방증입니다. 크롬·엣지도 V8이라는 JIT을 쓰는데 왜 안 죽을까요?

안티치트 입장에서 주요 브라우저는 예외 처리(화이트리스트) 대상이기 때문입니다. 게임하면서 브라우저 켜는 사람이 워낙 많아서 브라우저까지 죽이면 게임사가 항의를 감당 못 합니다. 반면 Bun으로 빌드된 낯선 콘솔 프로그램은 그런 배려를 받지 못합니다.

그래서 이런 결과가 나옵니다.

실행 방식게임 실행 중이유
브라우저로 쓰는 웹 AI 서비스정상브라우저는 안티치트 예외 대상
VSCode 확장 (내장 네이티브 바이너리)죽음Bun 프로세스
터미널에서 CLI 직접 실행죽음같은 Bun 프로세스
VSCode 자체정상Electron·크로미움 계열

해결 방법 — 확실한 순서대로

1. 시간을 분리한다 (가장 확실, 비용 0)

게임을 켜기 전에 CLI 세션과 터미널을 닫고, 게임을 끈 뒤 다시 여는 방식입니다. 시시해 보이지만 이게 유일하게 100% 확실한 방법입니다. 게임을 끄면 안티치트 드라이버도 내려가고 바로 정상 복귀합니다.

2. 브라우저 버전으로 우회한다

게임하면서 뭔가 물어볼 일이 있다면 웹 버전을 쓰면 됩니다. 위에서 확인했듯 브라우저는 안 죽습니다. 로컬 파일을 직접 고치는 작업은 못 하지만, 코드를 붙여넣고 물어보는 정도는 충분히 됩니다.

3. 다른 런타임 경로를 하나 더 둔다 (효과는 미보장)

네이티브 설치본(Bun) 말고 Node.js 기반으로 설치한 버전을 병행해 두는 방법입니다.

# 현재 어느 쪽으로 설치돼 있는지 확인
(Get-Command claude).Source
npm ls -g --depth=0

# Node 기반으로 추가 설치
npm install -g @anthropic-ai/claude-code

Node의 V8은 브라우저와 같은 엔진이라 안티치트가 덜 민감하게 볼 가능성이 있습니다. 다만 보장은 없고, VSCode 확장은 자체 번들 바이너리를 쓰기 때문에 확장은 여전히 죽습니다. 터미널 작업만 살릴 수 있는 부분 해결책입니다.

4. WSL2 — 이론상 최선이지만 함정이 있습니다

WSL2는 윈도우 안에서 진짜 리눅스 커널을 경량 가상머신으로 돌리는 기능입니다. WSL2 안의 프로세스는 윈도우 프로세스가 아니라 별도 VM 안의 리눅스 프로세스라서, 윈도우 커널에 붙은 안티치트의 후킹 범위 바깥에 있습니다. 원리만 보면 완벽한 해법입니다.

그런데 게임이 안 켜질 수 있습니다
WSL2를 켜면 윈도우 자체가 하이퍼바이저 위에서 도는 구조로 바뀝니다(Get-CimInstance Win32_ComputerSystemHypervisorPresentFalse에서 True로).

문제는 커널 안티치트들이 가상화 환경을 치팅 회피 수단으로 간주하는 경우가 있다는 점입니다. 개발 환경을 살리려다 게임 쪽 실행이 막힐 수 있습니다. 이미 WSL2나 Hyper-V를 쓰고 있고 게임도 잘 돌아간다면 상관없지만, 이 문제를 해결하려고 새로 WSL2를 켜는 건 권하지 않습니다.

내 PC에서 확인하는 방법

필자 환경에서는 게임을 켜기 전에는 같은 도구가 아무 문제 없이 돌아갔고(이 글을 쓰는 지금도 같은 VSCode 확장으로 작업 중입니다), 게임을 켠 뒤에는 터미널과 확장 두 경로 모두 같은 주소로 죽었으며, 게임을 끄자 아무것도 손대지 않았는데 원래대로 돌아왔습니다. 위 로그가 그 과정에서 그대로 받아낸 것입니다.

본인 환경에서도 확인하려면 순서는 간단합니다.

  1. 게임 끈 상태에서 CLI를 몇 번 실행 → 정상 동작하는지 확인 (대조군)
  2. 게임 켠 상태에서 같은 명령 → 크래시가 나는지 확인
  3. 게임을 다시 끄고 실행 → 아무 조치 없이 정상 복귀하는지 확인

1과 3에서 멀쩡하고 2에서만 죽으면 안티치트가 확정입니다. 이때 2단계의 Elapsed 값을 꼭 보세요. 게임이 이미 켜진 상태에서 새로 실행했는데 1초 안팎에 죽는다면, 자원 고갈이 아니라 초기화 단계에서 무언가 막고 있다는 신호입니다.

백신을 잠깐 꺼서 교차 확인해 보는 것도 좋습니다. 백신을 끈 상태로도 게임 중에는 여전히 죽는다면 백신은 완전히 무죄입니다.

정리

의심실측판정
메모리 부족34.3GB 중 0.29GB 사용 중 사망무죄
백신 차단제어된 폴더 액세스 꺼짐, 상시 실행인데 평소 정상무죄
Bun 자체 버그게임 끄면 아무 조치 없이 정상 동작무죄
게임 실행 자체켜면 두 경로 모두 사망, 끄면 복귀. 게임 기동 시각과 크래시 시각 일치확정
안티치트의 API 후킹 (메커니즘)Manual 시작 커널 서비스, 브라우저만 생존, 재실행 시 1초 즉사강한 정황

정확히 하자면 "게임을 켜면 죽고 끄면 산다"는 재현으로 확인된 사실이고, "안티치트가 JIT 메모리 할당을 가로채서"라는 부분은 정황에 근거한 설명입니다. 커널 드라이버 내부를 들여다볼 방법은 없으니까요. 다만 원인이 게임 쪽이라는 점만 알면 대처에는 충분합니다.

결론은 다소 허무합니다. 한 PC에서 안티치트 게임과 Bun 기반 CLI 도구를 동시에 돌리는 깔끔한 방법은 현재 없습니다. 안티치트는 설계상 시스템 전역을 감시해야 하고, JIT 런타임은 설계상 실행 가능 메모리를 계속 만들어야 하니 근본적으로 충돌합니다.

그래서 현실적인 답은 시간을 나누는 것입니다. 게임할 땐 게임하고, 코딩할 땐 코딩하는 거죠. 뭔가 대단한 우회로를 찾느라 반나절 쓰는 것보다, 원인을 알고 나서 그냥 창 하나 닫는 게 훨씬 빠릅니다. 필자도 이 결론에 도달하기까지 램 증설부터 검색했다가 로그를 다시 읽고서야 방향을 틀었습니다.

같은 계열의 안티치트를 쓰는 환경이라면 이름만 다를 뿐 증상은 같습니다. XIGNCODE3(xhunter1.sys), EasyAntiCheat, BattlEye, Vanguard(vgk.sys) 모두 커널 드라이버를 올리는 방식이라 같은 진단 절차가 그대로 적용됩니다.

728x90
반응형
LIST

+ Recent posts