Android WebView 뒤로가기 종료 및 외부 링크(Intent) 분기 처리 방법

Android WebView 뒤로가기 종료 및 외부 링크(Intent) 분기 처리 방법

하이브리드 앱을 개발하고 운영하다 보면 웹뷰(WebView) 안에서 뒤로 가기 처리나 외부 링크 분기 때문에 골치 아픈 일이 종종 생깁니다.

대충 퉁쳐서 처리하면 웹뷰에 방문 기록이 남아있는데도 앱이 픽 꺼져버리거나, 지도나 결제 창 같은 외부 링크가 웹뷰 안에서 깨진 채로 로딩되는 현상이 발생합니다. 심지어 검증되지 않은 도메인이 웹뷰 내에서 열려 보안 문제가 생기기도 하죠.

자꾸 헷갈리는 부분이라, 저의 경우 아예 기준을 명확히 잡고 베이스 코드로 만들어 두고 사용합니다. 나중에 다시 보려고 깔끔하게 정리해 둡니다.

1. 처리 기준 명확히 나누기

웹뷰 URL 처리는 무조건 아래 4가지 유형으로 딱 부러지게 나눠서 라우팅 정책을 잡아야 나중에 로그인이나 결제, 지도 기능이 추가되어도 꼬이지 않습니다.

구분처리 위치예시 URL / 스킴판단 및 처리 기준
내부 도메인WebView 내부https://www.example.com/page우리 서비스 정상 화면인지 확인 후 그대로 이동
외부 웹 링크외부 브라우저https://external.example서비스 밖 링크거나 새 창 성격이면 크롬 등으로 토스
앱 스킴외부 앱 / 마켓market://, tel:, mailto:Android Intent 처리 가능 여부 확인 후 실행
차단 대상로딩 차단알 수 없는 스킴보안 정책상 허용하지 않고 예외 처리

2. 핵심 구현 코드 (Java)

민감한 운영 도메인은 제외하고, 실무에서 바로 뼈대로 잡고 쓸 수 있는 실제 구현 코드입니다. 최신 Android 트렌드에 맞춰 onBackPressed() 대신 OnBackPressedDispatcher를 사용했습니다.

Java
public class MainActivity extends AppCompatActivity {
    // 운영하시는 서비스 도메인을 입력하세요
    private static final String ALLOWED_HOST = "www.example.com"; 
    private static final long EXIT_INTERVAL_MS = 2000L;

    private WebView webView;
    private long lastBackPressedAt = 0L;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = findViewById(R.id.webView);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.getSettings().setDomStorageEnabled(true);

        // 1. URL 로딩 정책 설정
        webView.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                return handleUrl(request.getUrl());
            }
        });

        // 2. 뒤로가기 콜백 등록 (최신 API 대응)
        getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
            @Override
            public void handleOnBackPressed() {
                handleBackPressed();
            }
        });

        webView.loadUrl("https://" + ALLOWED_HOST);
    }

    /**
     * URL 라우팅 처리 처리
     */
    private boolean handleUrl(Uri uri) {
        String scheme = uri.getScheme();
        String host = uri.getHost();

        // 내부 서비스 도메인인 경우 -> 웹뷰 내부에서 로딩 (false 반환)
        if (("http".equals(scheme) || "https".equals(scheme))
                && ALLOWED_HOST.equals(host)) {
            return false; 
        }

        // 외부 웹 링크 및 주요 앱 스킴 처리 -> 외부 앱 호출 (true 반환)
        if ("http".equals(scheme) || "https".equals(scheme)
                || "tel".equals(scheme) || "mailto".equals(scheme)
                || "market".equals(scheme)) {
            openExternal(uri);
            return true;
        }

        // 허용되지 않거나 알 수 없는 스킴 차단
        Toast.makeText(this, "지원하지 않는 링크입니다.", Toast.LENGTH_SHORT).show();
        return true;
    }

    /**
     * 외부 앱(Intent) 실행
     */
    private void openExternal(Uri uri) {
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        if (intent.resolveActivity(getPackageManager()) != null) {
            startActivity(intent);
        } else {
            Toast.makeText(this, "실행할 수 있는 앱이 없습니다.", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 뒤로가기 흐름 제어 (웹뷰 히스토리 vs 앱 종료 UX)
     */
    private void handleBackPressed() {
        // 웹뷰 자체 뒤로가기 내역이 있다면 웹뷰 내에서 뒤로가기
        if (webView.canGoBack()) {
            webView.goBack();
            return;
        }

        // 히스토리가 없다면 '두 번 눌러 종료' UX 적용
        long now = System.currentTimeMillis();
        if (now - lastBackPressedAt < EXIT_INTERVAL_MS) {
            finish();
            return;
        }

        lastBackPressedAt = now;
        Toast.makeText(this, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show();
    }
}

3. 코드 작성할 때 주의할 점 (자주 하는 실수)

shouldOverrideUrlLoading 안에서 view.loadUrl()을 다시 호출하지 마세요.

내부 이동이라서 웹뷰로 보여줘야 할 때, 간혹 view.loadUrl(url); return true; 형태로 코딩하는 경우가 있습니다. 이렇게 하면 리다이렉션이 걸렸을 때 무한 루프에 빠지거나 히스토리가 꼬입니다. 내부 이동은 그냥 return false;를 던져서 웹뷰가 알아서 로딩하게 두는 것이 정석입니다.

canGoBack()만 믿고 종료 처리를 날리면 안 됩니다.

방문 기록이 없을 때 곧바로 finish()를 호출하면 메인 화면에서 사용자가 의도치 않게 앱을 꺼버리는 불쾌한 경험을 줍니다. 위 코드처럼 두 번 연속 눌렀을 때만 꺼지도록 완충 장치(EXIT_INTERVAL_MS)를 주는 것이 좋습니다.

4. 실무 운영 체크리스트

  • [ ] Allowlist 관리: 앱 내부에서 열 수 있는 도메인은 하드코딩보다 변수나 파일(배포 정책)로 따로 빼서 관리하는 것이 안전합니다.

  • [ ] 보안 파라미터: 외부 브라우저(openExternal)로 넘길 때 세션 토큰이나 사용자 식별자(UID) 같은 민감한 값이 URL 파라미터에 그대로 노출되지 않는지 확인하세요.

  • [ ] 크래시 방지: intent://나 특정 카드사 결제 스킴처럼 기기에 해당 앱이 없을 때 앱이 뻗지 않도록 resolveActivity 검증이나 try-catch 예외 처리를 꼼꼼히 해야 합니다.

이렇게 URL 라우팅 로직(handleUrl)과 뒤로가기 로직(handleBackPressed)을 딱 분리해 두면, 나중에 카카오톡 공유 기능이나 다른 외부 스킴 연동 요청이 들어와도 기존 코드를 건드리지 않고 분기문만 싹 추가할 수 있어서 유지보수가 정말 편해집니다. 웹뷰 앱 만드실 때 기본 템플릿으로 활용해 보세요.

댓글

이 블로그의 인기 게시물

안드로이드 화면 꺼짐 방지 FLAG_KEEP_SCREEN_ON 및 WakeLock 적용 방법

안드로이드 백그라운드 타이머 구현 및 Foreground Service 알림 설계 정리

관광 앱 다국어 데이터 모델 및 검색 색인 설계 (자꾸 까먹어서 정리)