안드로이드 뒤로가기 두 번 눌러 종료 UX 올바른 구현 방법 (웹뷰 히스토리 및 Android 15 대응)
이래저래 웹뷰 기반 앱이나 메인 화면 위주의 간단한 앱을 만들고 운영하다 보면, 사용자가 실수로 앱을 종료하는 걸 막으려고 “뒤로가기 한 번 더 누르면 종료됩니다”라는 토스트 안내를 자주 넣게 됩니다.
저의 경우도 예전부터 습관적으로 구현하던 방식인데요. 이게 아무 생각 없이 모든 화면에 다 적용해 버리면 꼭 운영 중에 문제가 터집니다. 웹뷰에 뒤로 갈 페이지가 남아 있는데 갑자기 종료 토스트가 뜨거나, 상세 화면에서 이전 화면으로 돌아가야 하는데 종료 대기 상태가 되는 식이죠.
특히 요즘 Android 13부터 Android 15 버전까지 이어지는 '예측 뒤로가기(Predictive Back)' 시스템 애니메이션 흐름까지 고려하면, 옛날 방식인 onBackPressed() 오버라이드나 KeyEvent 가로채기로 대충 짜면 안 됩니다. 나중에 제가 다시 보려고 실무 적용 기준으로 순서와 코드를 정리해 둡니다.
내가 겪은 문제와 자주 실수하는 지점
뒤로가기 로직을 정교하게 다듬지 않으면 아래와 같은 문제가 생깁니다. 보통 초보 시절에 코드 복사해서 대충 붙여넣을 때 많이 발생하곤 합니다.
웹뷰 히스토리 꼬임: 웹뷰 안에 이전 페이지가 엄연히 남아 있는데도 처리 안 하고 바로 종료 토스트가 뜨는 현상
상세 화면 갇힘: 메인 탭이 아니라 상세 페이지나 설정 화면인데 이전 화면으로 안 가고 종료 UX가 작동하는 문제
스택 무시 종료: 아직 뒤로 갈 Activity나 Fragment stack이 남아 있는데 앱이 그냥 닫히는 현상
구형 API 의존: 구형
onBackPressed()나 키 이벤트 가로채기에 의존해서 최신 안드로이드 시스템 백 애니메이션과 충돌하는 문제일관성 없는 UX: 화면마다 토스트 문구나 종료 대기 시간 기준이 제각각인 현상
운영 관점에서 보면 뒤로가기를 단순히 “앱 종료 버튼”처럼 다루면 안 됩니다. 사용자는 뒤로가기를 눌렀을 때 이전 웹 페이지, 이전 탭, 이전 화면, 이전 앱 상태 순서로 자연스럽게 돌아가기를 기대합니다.
기본 개념: 뒤로가기는 안쪽 계층부터 차례대로 처리
뒤로가기 처리는 무조건 '가장 안쪽(가장 마지막에 열린 상태)'부터 확인해서 차례대로 거슬러 올라와야 자연스럽습니다. 저는 보통 아래의 우선순위로 검토해서 처리합니다.
| 우선순위 | 확인 대상 | 처리 방식 |
| 1 | 다이얼로그, 바텀시트, 검색창 | 열려 있는 UI 요소를 먼저 닫습니다. |
| 2 | WebView 히스토리 | canGoBack()이 true면 goBack()을 실행합니다. |
| 3 | Fragment / Navigation stack | 이전 화면으로 pop 처리합니다. |
| 4 | 메인 탭 내부 상태 | 기본 탭으로 이동하거나 화면 상태를 초기화합니다. |
| 5 | 루트 화면 (최종) | 더 이상 되돌릴 상태가 없을 때 두 번 눌러 종료 UX를 적용합니다. |
즉, 두 번 눌러 종료는 무조건 마지막 단계입니다. 화면 안에서 되돌릴 상태가 모두 사라진 뒤 루트 화면에 있을 때만 작동해야 합니다.
실무 적용 핵심 예시 코드
기존 구형 코드는 버리고, AndroidX의 OnBackPressedDispatcher를 사용하는 방식입니다. 웹뷰 히스토리를 먼저 체크하고 최종 루트 화면일 때만 종료 토스트를 띄우도록 제어합니다. 예시용이라 도메인이나 복잡한 비즈니스 로직은 걷어낸 기본 구조입니다.
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var lastBackPressedAt: Long = 0L
private val exitIntervalMs = 2_000L // 종료 대기 시간 (2초)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView)
// 구형 onBackPressed 대신 OnBackPressedDispatcher 사용
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleBackNavigation()
}
})
}
private fun handleBackNavigation() {
// 1. 다이얼로그나 모달 등 열린 UI가 있으면 먼저 닫기
if (closeOpenedUiState()) {
return
}
// 2. 웹뷰 히스토리가 있으면 이전 페이지로 이동
if (webView.canGoBack()) {
webView.goBack()
return
}
// 3. 루트 화면이 아니라면 이전 스택으로 이동
if (!isRootScreen()) {
navigateBack()
return
}
// 4. 진짜 최외곽 루트 화면일 때만 두 번 눌러 종료 처리
handleDoubleBackExit()
}
private fun closeOpenedUiState(): Boolean {
// 예시: 검색창, 바텀시트, 다이얼로그 등이 열려 있으면 닫고 true 반환
return false
}
private fun isRootScreen(): Boolean {
// 예시: 메인 탭 첫 화면이거나 Navigation stack이 비어 있는지 확인
return true
}
private fun navigateBack() {
// 예시: FragmentManager 또는 NavController popBackStack 처리
}
private fun handleDoubleBackExit() {
val now = System.currentTimeMillis()
if (now - lastBackPressedAt <= exitIntervalMs) {
finish()
return
}
lastBackPressedAt = now
Toast.makeText(this, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show()
}
}
이 코드에서 핵심은 handleDoubleBackExit()가 냅다 먼저 실행되지 않는다는 점입니다. 닫을 UI 상태가 있는지, 웹뷰 히스토리가 있는지, 화면 스택이 남아 있는지 전부 필터링한 뒤 마지막에만 안전하게 종료 UX를 적용합니다.
웹뷰 앱 및 최신 안드로이드 대응 가이드
1. 웹뷰 안에서의 판단 기준
웹뷰 기반 앱은 웹 페이지 히스토리와 네이티브 화면 스택이 섞이기 쉬워서 운영할 때 꼼꼼하게 봐야 합니다. 웹뷰 URL이 메인 페이지인지 판단할 때 실제 도메인 문자열을 코드 이곳저곳에 하드코딩해서 흩뿌리지 않는 것이 좋습니다. 운영 도메인은 allowlist나 별도 route helper 클래스로 따로 빼서 관리하시고, 테스트나 예제에서는 example.com 같은 더미 도메인을 쓰시는 걸 추천합니다.
외부 브라우저에서 돌아온 직후: 외부 연동 후 복귀 시 앱 상태를 유지해야 하므로 종료 UX가 즉시 실행되지 않도록 예외 관리가 필요합니다.
로딩 오류 화면: 웹뷰가 터지거나 에러 페이지일 때는 종료하기보다 재시도나 메인 이동 동선을 우선 제공하는 편이 좋습니다.
2. 최신 Android Back API 고려 (Android 13 ~ 15)
안드로이드 공식 문서에서도 기존 onBackPressed()나 KeyEvent.KEYCODE_BACK 가로채기 방식을 피하라고 명시하고 있습니다. 특히 Android 15 이상부터는 예측 뒤로가기(Predictive Back) 시스템 애니메이션이 기본으로 활성화되는 방향입니다. 사용자가 백 제스처를 할 때 홈 화면이 살짝 보이면서 돌아갈 대상을 미리 보여주는 구조인데, 루트 화면 백을 앱이 무조건 intercept해서 끊어버리면 시스템 애니메이션과 충돌하면서 사용자 경험이 툭툭 끊기게 됩니다.
구현 시
OnBackPressedDispatcher나 Jetpack Compose의BackHandler같은 표준 API를 검토해서 시스템 흐름을 타도록 구현해야 안전합니다.
3. UX 문구와 시간 기준 통일
안내 문구:
“한 번 더 누르면 종료됩니다.”정도로 군더더기 없이 짧게 작성하는 것이 좋습니다. 화면마다 문구가 달라지면 일관성이 깨집니다.대기 시간: 보통
1.5초 ~ 2.5초범위가 가장 적당합니다. 너무 길면 대기 상태를 오해하고, 너무 짧으면 연타해야 해서 실수 방지 효과가 떨어집니다.표시 방식: 앱 스타일에 맞게 Toast나 Snackbar 중 하나로 전체 앱을 통일하세요.
요약 및 운영 체크리스트
나중에 프로젝트 배포 전에 아래 리스트대로 작동하는지 기기나 에뮬레이터에서 꼭 한 번씩 테스트해 보세요.
[ ] 뒤로가기 처리 순서가 UI 상태 → WebView → Navigation stack → 루트 종료 순서로 보장되는가?
[ ] 코드 내에 실서버 URL이나 도메인이 하드코딩으로 박혀있지 않은가?
[ ] 구형
onBackPressed()오버라이드나KeyEvent기반 처리를 걷어냈는가?[ ] Android 13~15 기기에서 예측 뒤로가기 제스처 시 애니메이션이 깨지지 않고 부드럽게 작동하는가?
[ ] 토스트 문구와 대기 시간이 앱 전체에서 일관되게 고정되어 있는가?
“두 번 눌러 종료”는 여전히 실수를 막아주는 좋은 완충 장치지만, 모든 화면의 기본값이 되어서는 안 됩니다. 닫을 상태가 아무것도 없을 때 나오는 '최종 보조 장치'라는 점을 생각하고 설계하면 훨씬 자연스러운 앱을 만들 수 있습니다.
댓글
댓글 쓰기