안녕하세요, 오늘은 프로젝트에서 웹뷰 연동을 했던 내용을 정리해보려고 합니다!
1) 홈과 마이페이지를 웹뷰로 띄우고,
2) 웹 개발자 분들이 주신 프로토콜에 맞춰 앱에 저장한 토큰을 넘기고,
3) 웹에서 네이티브로 넘기는 메시지를 통해 홈에서의 화면 이동 및 마이페이지에서의 로그아웃, 회원탈퇴 처리를 한 과정 전반을 공유해보겠습니다.
진행하던 공모전용 프로젝트에서 홈과 마이페이지는 웹뷰로 보여주어야 한다는 요구사항이 있었습니다.
진행해야 하는 작업은 크게 아래의 4가지였습니다.
- WebView 띄우기
- 웹으로 토큰을 넘기기
- 웹뷰 내 컴포넌트 클릭 시 화면 이동 시키기 (앱에서 지원하는 화면)
- 마이페이지 로그아웃, 회원탈퇴 시 앱 내 저장된 토큰 삭제 + 로그인 화면으로 이동
이를 위해서는 웹-네이티브 통신 프로토콜을 맞춰야 했고, 웹 개발자 분들이 브릿지 관련 코드를 정리해서 문서로 제공해 주셨습니다.
![]() | ![]() |
---|
그럼, 각 작업에 대해 순차적으로 코드를 설명드려 보겠습니다!
기본 설정에 관한 내용입니다.
Constants에 아래와 같이 웹 주소와 endPoint 설정을 해줬습니다.
object Constants {
const val WEB_BASE_URL = BuildConfig.WEB_BASE_URL // 웹 주소
// 웹 endPoint
const val ENDPOINT_HOME = "/" // 홈
const val ENDPOINT_MY = "/my-page" // 마이페이지
}
그리고, 기본 웹뷰를 띄우는 코드는 아래와 같이 작성해 줬어요.
@SuppressLint("SetJavaScriptEnabled")
private fun initWebViewSetting() {
binding.homeWebView.apply {
// 세팅
settings.javaScriptEnabled = true // JavaScript를 사용한 웹뷰를 로드한다면 활성화 필요
settings.loadWithOverviewMode = true // 컨텐츠의 크기가 WebView 보다 클 경우, 스크린에 맞게 자동 조정
webViewClient = WebViewClient()
loadUrl("$WEB_BASE_URL$ENDPOINT_HOME")
// WebView 뒤로가기 설정
setOnKeyListener(View.OnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) return@OnKeyListener true
// 뒤로가기 버튼을 눌렀을 때, WebView에서 뒤로가기가 된다면 뒤로가고 아니라면 종료
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (this.canGoBack()) {
this.goBack()
} else {
requireActivity().onBackPressedDispatcher.addCallback(object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {}
})
}
return@OnKeyListener true
}
false
})
}
}
웹뷰에서의 뒤로가기 지원을 하기 위해 setOnKeyListener
를 추가해 줍니다.
enum class MessageType {
TOKEN, PAGE_CHANGE, TOKEN_EXPIRED;
companion object {
fun findMessageType(messageTypeString: String): MessageType {
return MessageType.valueOf(messageTypeString)
}
}
}
data class TokenPayload(
val token: String
)
data class NativeTokenRequestMessage(
val type: String = MessageType.TOKEN.name,
val payload: TokenPayload
)
웹 측에서 제공한 문서대로 MessageType
를 enum class로 미리 만들었습니다.
그리고 아래 형식대로 토큰을 전달할 때 사용할 data class도 미리 만들어 줍니다.
interface NativeMessageCallback {
fun onReactComponentLoaded(boolean: Boolean) // 페이지 로드가 완료됐을 때
}
abstract class WebViewBridge(private val callback: NativeMessageCallback) {
@JavascriptInterface
fun sendMessageToNative(message: String) {
Log.d("WebViewBridge", message)
if (message.contains("loaded")) {
callback.onReactComponentLoaded(true)
}
}
companion object {
const val INTF = "Android"
}
}
class HomeFragment : Fragment(), NativeMessageCallback {
@SuppressLint("SetJavaScriptEnabled")
private fun initWebViewSetting() {
// 웹뷰 설정
binding.myWebView.apply {
// ... 세팅
settings.domStorageEnabled = true // 웹뷰에서 LocalStorage를 사용해야 하는 경우 활성화 필요
addJavascriptInterface(object : WebViewBridge(this@MyFragment) {}, WebViewBridge.INTF)
webViewClient = WebViewClient()
loadUrl("$WEB_BASE_URL$ENDPOINT_HOME") // 웹 주소
// ... WebView 뒤로가기 설정
}
}
// 네이티브 앱에서 웹뷰로 TOKEN 메시지 보내기
private fun sendTokenToWebView() {
binding.homeWebView.evaluateJavascript("javascript:sendMessageToWebView(${getRequestMessage()})", null)
}
private fun getRequestMessage(): String {
val nativeMessage = NativeTokenRequestMessage(
payload = TokenPayload(getSavedAccessToken())
)
return Gson().toJson(nativeMessage)
}
// 앱 내 저장된 토큰 정보 가져오기
private fun getSavedAccessToken(): String = runBlocking {
dsManager.getAccessToken().first().orEmpty()
}
override fun onReactComponentLoaded(boolean: Boolean) {
// 0.5초 지연 후 토큰 전송
Handler(Looper.getMainLooper()).postDelayed({
sendTokenToWebView()
}, 500)
}
}
사실 이 토큰을 보내는 과정이 많이 힘들었는데요,,
브릿지 코드를 작성하고 난 뒤에도 토큰을 제대로 처리하지 못해 찾아보니 웹뷰 세팅에 domStorageEnabled = true
설정을 해줘야 했습니다. 웹뷰에서 LocalStorage를 사용해야 하는 경우 활성화해야 한다고 합니다.
그리고 페이지 로드가 완료된 이후 토큰을 전송해야지 제대로 보내지더라구요!
이를 위해 브릿지 코드에서 message로 'load'가 왔을 때 콜백 처리를 해주었습니다.
페이지 로드 후 바로 토큰을 전송하면 또 잘 안 먹어서, 0.5초 delay를 준 뒤 토큰을 전송해 줬습니다.
![]() | ![]() |
---|
웹 개발자 분께서 토큰이 잘 전달되었는지 확인할 용도로 작업해주신 부분인데요, 우측 화면을 보면 상단에 웹으로 전달한 토큰이 잘 뜨는 것을 볼 수 있고, 웹에서 토큰을 잘 받아서 '오늘의 추천 루트', '오늘의 인기 루트' 콘텐츠도 잘 불러오는 것을 확인할 수 있었습니다.
홈에서의 화면 이동을 예시로 들어보겠습니다.
enum class WebViewPage(val viewName: String) {
MY_ROUTE("MY_ROUTE"),
SEARCH("SEARCH"),
ROUTE("ROUTE"),
COUPON("COUPON"),
LOGOUT("LOGOUT"),
WITHDRAW("WITHDRAW");
companion object {
fun findPage(pageString: String): WebViewPage {
return entries.find { it.viewName == pageString }
?: throw IllegalArgumentException("Invalid page name: $pageString")
}
}
}
앞선 토큰 전달 시에 MessageType
을 미리 정의해뒀었는데요,
이번에는 MessageType이 'PAGE_CHANGE'
이라면 화면을 이동시켜 줄, 페이지 이름을 저장해 둡니다.
웹에서 주는 문자열 그대로 전환 코드를 작성해도 무방하지만, 저는 프래그먼트 단에서 when문으로 명확하게 처리해주고 싶어서 enum class를 만들어 주었어요!
interface NativeMessageCallback {
//...
fun onMessageReceived(type: MessageType, page: WebViewPage, id: String?) // 웹에서 메시지를 받았을 때
}
{
"type": "PAGE_CHANGE",
"payload": {
"page": "<페이지_식별자>",
"id": "<선택적_ID>"
}
}
메시지 유형이 'PAGE_CHANGE'
인 경우 기본적인 구조입니다.
이에, 콜백으로 전달할 인터페이스 내에 onMessageReceived
함수를 만들어 메시지와 페이지 유형, 그리고 (선택적으로) id를 전달하게끔 설정해 줍니다.
abstract class WebViewBridge(private val callback: NativeMessageCallback) {
@JavascriptInterface
fun sendMessageToNative(message: String) {
// ...
try {
// 전달된 문자열을 JSONObject로 변환
val jsonObject = JSONObject(message)
// type 필드 값 가져오기
val type = jsonObject.getString("type")
// payload 객체가 존재하는지 체크
if (jsonObject.has("payload")) {
// payload가 있을 경우 처리
val payload = jsonObject.getJSONObject("payload")
val page = payload.getString("page")
val id = if (payload.has("id")) payload.getString("id") else null
Log.d("WebViewBridge", "Type: $type, Page: $page, ID: $id")
// 콜백 호출하여 데이터를 전달
callback.onMessageReceived(MessageType.findMessageType(type), WebViewPage.findPage(page), id)
}
} catch (e: JSONException) {
e.printStackTrace()
}
}
companion object {
const val INTF = "Android"
}
}
웹에서는 json 형태로 데이터를 주기 때면에 JSONObject를 파싱해서 원하는 형태로 바꿔주고, 콜백으로 전달해줍니다.
![]() | ![]() |
---|
웹에서 주는 메시지의 예시입니다. 크게 type
과 payload
로 나뉘는 걸 볼 수 있습니다.
때문에 payload가 있다면 값을 가져오는 코드도 작성해 주었습니다.
override fun onMessageReceived(type: MessageType, page: WebViewPage, id: String) {
when (page) {
WebViewPage.MY_ROUTE -> { // 내 루트 탭으로 이동
selectBottomNavTab(R.id.myRouteFragment)
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToMyRouteFragment())
}
WebViewPage.SEARCH -> { // 탐색 탭으로 이동
selectBottomNavTab(R.id.seekFragment)
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToSeekFragment())
}
WebViewPage.ROUTE -> { // 루트 조회 화면으로 이동
startActivity(
Intent(
requireActivity(),
RoutePreviewDetailActivity::class.java
).putExtra("routeId", id?.toInt())
)
}
else -> Log.d("HomeFragment", "Unknown page: $page")
}
}
웹뷰의 콘텐츠를 클릭하면 웹에서 이동할 화면을 메시지로 보내주는데, 웹이 전달해 준 페이지 정보대로 화면을 이동해줄 수 있습니다.
해당 페이지들은 웹에서 구현하지 않고, 네이티브에서 구현했기 때문에 이처럼 웹과 약속한 메시지를 통해 앱 내에서 화면 이동 코드를 작성해야 했습니다.
웹과 논의했을 때, 웹뷰로 구현된 마이페이지 내에서 로그아웃 및 회원탈퇴를 하면 payload 없이 바로 type에 LOGOUT, WITHDRAW를 적어 보내주기로 했어요.
응답 예)
{"type":"LOGOUT"} // WITHDRAW
웹에서 구현한 화면 상으로는 아래 모습입니다!
![]() | ![]() |
---|
이 부분은 마이페이지와 설정 모두 웹에서 구현한 부분이기에, 설정으로 이동하는 경우는 PAGE_CHANGE
전달 없이 웹뷰 아이콘을 클릭하면 바로 설정 화면으로 이동합니다.
웹뷰로 띄워주는 설정창에서 '로그아웃', '회원탈퇴' 버튼을 클릭했을 때 API 호출도 웹에서 진행합니다.
다만, 웹에서 로그아웃과 회원탈퇴 버튼을 클릭했을 때
1) 앱 내 저장된 토큰도 삭제해 주어야 함
2) 로그인 화면으로 이동시켜 줘야 함
이 두 가지 처리를 해줘야 했기에, 이 부분도 웹으로부터 메시지를 전달받아야 했습니다.
interface NativeMessageCallback {
// ...
fun onMyPageMessageReceive(page: WebViewPage) // 로그아웃, 회원탈퇴 용
}
abstract class WebViewBridge(private val callback: NativeMessageCallback) {
@JavascriptInterface
fun sendMessageToNative(message: String) {
// ...
try {
// 전달된 문자열을 JSONObject로 변환
val jsonObject = JSONObject(message)
// type 필드 값 가져오기
val type = jsonObject.getString("type")
if (jsonObject.has("payload")) { // payload 객체가 존재하는지 체크
// ...
} else { // payload가 없을 경우 (e.g., LOGOUT, WITHDRAW)
callback.onMyPageMessageReceive(WebViewPage.findPage(type)) // 마이페이지 처리
}
} catch (e: JSONException) {
e.printStackTrace()
}
}
}
override fun onMyPageMessageReceive(page: WebViewPage) {
when (page) {
WebViewPage.LOGOUT -> { // 로그아웃
lifecycleScope.launch {
deleteToken()
moveToLoginActivity()
}
}
WebViewPage.WITHDRAW -> { // 회원탈퇴
lifecycleScope.launch {
deleteToken()
moveToLoginActivity()
}
}
else -> Log.d("MyFragment", "Unknown page: $page")
}
}
private fun moveToLoginActivity() {
requireActivity().startActivity(Intent(requireActivity(), LoginActivity::class.java))
requireActivity().finish()
}
private suspend fun deleteToken() {
dsManager.clearTokens()
}
Home과 마찬가지로 NativeMessageCallback
를 상속하고, 웹뷰 기본 세팅 코드를 작성해 준 뒤 onMyPageMessageReceive
를 오버라이드 해 로그아웃과 회원탈퇴 처리를 해줄 수 있었습니다.
![]() 네이티브 -> 웹 토큰 전달 | ![]() 웹 -> 네이티브 화면 이동 |
---|
아직 보완할 부분은 조금 있지만, 어쨌든 앱에서 웹으로 토큰을 저장해서 웹뷰를 불러오는 것과 웹뷰에서의 클릭 이벤트를 받아와 화면을 이동시키는 것까지 잘 동작하는 모습을 확인할 수 있었습니다!
오늘은 이렇듯 웹뷰를 띄우며 웹에 메시지를 전달하고 받아오는 전반을 정리해 보았습니다.
브릿지의 개념도 처음 알게 되었고.., 항상 서버와만 통신했지, 웹과 이렇게 프로토콜에 맞춰 통신해 본 경험은 처음이라 무척 새로웠습니다.
기존에는 WebView로 브라우저를 띄우는 정도만 해봤지, 통신 코드를 작성했던 적은 없었는데요. 웹과 통신을 위해 필요한 기본 개념이 정말 많더라구요.. 기본 개념조차 몰랐던 기능을 개발하는 과정은 정말 쉽지 않았습니다. 구글에 이런저런 자료는 많이 나왔지만, 자료마다 코드 형태가 꽤 다양해서 '어떤 게 지금 내 상황에 맞는 코드지?'를 판단하고, 채택하는 게 어려웠던 것 같습니다.
특히, 참고했던 문서에서 웹 코드가 어떻게 동작하는 지를 확인하기 힘들었어서.. 제 프로젝트의 웹과 iOS 레포지토리에 가서 코드를 참고해 보기도 했습니다. '이걸 이 형태로 보내는 게 맞나? 이 형태로 코드를 작성하면 보내/받아지는 건가?'가 가장.. 헷갈리지 않았나ㅜㅜ 싶어요.
연동 과정에서 많은 어려움이 있었고, 웹 담당자 분과도 많이 이야기하고, 웹 개발자 분이 직접 안드로이드에서 연동을 어떻게 하는지 테스트해봐 주시기도 했습니다. 특히 토큰 전달이 어려웠는데, 세팅에서 domStorageEnabled
를 설정해준 뒤에 잘 되는 것을 확인하자, 화면 이동과 로그아웃 같은 나머지 부분은 조금 수월했어요.
급하게 구현한 코드라 마음에 안 드는 부분이 많아서, 언제 한 번 코드를 조금 다듬고 다시 정리해 보겠습니다.
어쨌든 간에, 간만에 완전히 새로운 개념과 기술을 구현하게 되어 어려웠지만 정말 즐거웠던 작업이었습니다ㅎㅎ