์›น๋ทฐ ๋ธŒ๋ฆฟ์ง€ ๊ฐœ์„ ๊ธฐ: ๋ฒ„์ „ ๋ถ„๊ธฐ ์ง€์˜ฅ์—์„œ ๋ฒ—์–ด๋‚˜๊ธฐ

soojin
  • #WebView
  • #Web
  • #App
  • #Bridge Interface

๋“ค์–ด๊ฐ€๋ฉฐ

์•ˆ๋…•ํ•˜์„ธ์š”, ๋…ธ๋จธ์Šค Android ๊ฐœ๋ฐœ์ž ์–‘์ˆ˜์ง„์ž…๋‹ˆ๋‹ค. ์›น๋ทฐ ํ™˜๊ฒฝ์—์„œ๋Š” ์›น๊ณผ ๋„ค์ดํ‹ฐ๋ธŒ ์•ฑ์ด ์„œ๋กœ ํ†ต์‹ ํ•˜๊ธฐ ์œ„ํ•œ ๋ธŒ๋ฆฟ์ง€(Bridge) ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ํ•„์ˆ˜์ ์ธ๋ฐ์š”, ๊ธฐ์กด ๋ธŒ๋ฆฟ์ง€ ์ฒด๊ณ„๋ฅผ ์šด์˜ํ•˜๋ฉด์„œ ์—ฌ๋Ÿฌ ๋ถˆํŽธํ•จ๊ณผ ๊ตฌ์กฐ์  ํ•œ๊ณ„๋ฅผ ๋А๋ผ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ด ๊ธ€์—์„œ๋Š” ๊ธฐ์กด ๋ธŒ๋ฆฟ์ง€ ๊ตฌ์กฐ์—์„œ ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋Š”์ง€ ๋ถ„์„ํ•˜๊ณ , ๋‹จ์ผ ๋ธŒ๋ฆฟ์ง€ ํ•จ์ˆ˜ + type ๊ธฐ๋ฐ˜ ๋ถ„๊ธฐ ์ฒ˜๋ฆฌ ๋ฐฉ์‹์œผ๋กœ ์žฌ์„ค๊ณ„ํ•œ ๊ณผ์ •์„ ๊ณต์œ ํ•˜๋ ค ํ•ฉ๋‹ˆ๋‹ค.

1. ๊ธฐ์กด ๊ตฌ์กฐ์˜ ๋ฌธ์ œ์ 

๊ฐœ์„  ์ž‘์—…์— ์•ž์„œ, ๊ธฐ์กด ๋ธŒ๋ฆฟ์ง€ ์ฒด๊ณ„์—์„œ ๊ฒช๊ณ  ์žˆ๋˜ ๋ฌธ์ œ์ ๋“ค์„ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.

1-1. ๋ฒ„์ „ ๋ถ„๊ธฐ ๋กœ์ง์˜ ์ด์ค‘ ์‚ฐ์žฌ

๊ฐ€์žฅ ํฌ๊ฒŒ ์ฒด๊ฐํ–ˆ๋˜ ๋ฌธ์ œ๋Š” ๋ฒ„์ „ ๋ถ„๊ธฐ ๋กœ์ง์ด ์—ฌ๊ธฐ์ €๊ธฐ ํฉ์–ด์ ธ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด์—ˆ์Šต๋‹ˆ๋‹ค. ์›น์—์„œ ๋„ค์ดํ‹ฐ๋ธŒ ์ปค๋งจ๋“œ๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ, checkAppVersion์œผ๋กœ ์•ฑ ๋ฒ„์ „์„ ํ™•์ธํ•˜๋Š” ๋กœ์ง์ด ํ˜ธ์ถœ๋ถ€์™€ runCommand.ts ๋‚ด๋ถ€ ๋‘ ๊ณณ์—์„œ ์ค‘๋ณต์œผ๋กœ ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค.

if (checkAppVersion(VersionDict.MultiImage)) {
    return runCommand({ key: 'openEditPostFanFeed', postId, channelId, ... });
}
showVersionUpdateAlert(() => {
    if (checkAppVersion('1.29.0')) {
        runCommand({ key: 'openEditPostFanFeed', postId, channelId, ... });
        return;
    }
    // 1.29.0 ๋ฏธ๋งŒ: ์›น ์—๋””ํ„ฐ ๋‹ค์ด์–ผ๋กœ๊ทธ๋กœ ํด๋ฐฑ
    showDialog(<FeedPostEditPage post={post} />);
});
// runCommand.ts ๋‚ด๋ถ€ โ€” ๊ฐ™์€ ์ปค๋งจ๋“œ์— ๋Œ€ํ•ด ๋ฒ„์ „๋ณ„๋กœ ๋‹ค๋ฅธ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ „์†ก
ios({ postId, channelId, boardId, hasMembership }) {
    if (checkAppVersion(VersionDict.MultiImage)) {      // 1.37.0+
        return postMessage({ data: JSON.stringify({ postId, channelId, boardId, hasMembership }) });
    }
    if (checkAppVersion(VersionDict.Board)) {            // 1.31.0+
        return postMessage({ data: JSON.stringify({ postId, channelId, boardId }) });
    }
    postMessage({ data: JSON.stringify({ postId, channelId }) });   // ๊ทธ ์ดํ•˜
}

์ด๋Ÿฐ ์‹์˜ ๋ฒ„์ „ ๋ถ„๊ธฐ๊ฐ€ ์—ฌ๋Ÿฌ ํŒŒ์ผ์— ๊ฑธ์ณ ์‚ฐ์žฌํ•ด ์žˆ์—ˆ๊ณ , ํ•˜๋“œ์ฝ”๋”ฉ๋œ ๋ฒ„์ „ ๋ฌธ์ž์—ด๋„ ๊ณณ๊ณณ์— ํฉ์–ด์ ธ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

1-2. ๊ทผ๋ณธ ์›์ธ โ€” ์—๋Ÿฌ ํ•ธ๋“ค๋ง์˜ ๋ถ€์žฌ

๊ทธ๋Ÿฐ๋ฐ ์™œ ์ด๋ ‡๊ฒŒ ๋ฒ„์ „ ๋ถ„๊ธฐ๊ฐ€ ๊ณณ๊ณณ์— ์ƒ๊ฒจ๋‚œ ๊ฑธ๊นŒ์š”? ๊ทผ๋ณธ ์›์ธ์€ ์–‘ ํ”Œ๋žซํผ ๋ชจ๋‘ ์›น์—์„œ ๋ฏธ์ง€์› ์ปค๋งจ๋“œ๋ฅผ ํ˜ธ์ถœํ–ˆ์„ ๋•Œ ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ์—†์—ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์•ฑ์ด โ€œ์ด ์ปค๋งจ๋“œ๋Š” ๋ชจ๋ฅธ๋‹คโ€๊ณ  ์•Œ๋ ค์ค„ ์ˆ˜ ์—†์œผ๋‹ˆ, ์›น์—์„œ ๋ฏธ๋ฆฌ ๋ฒ„์ „์„ ํ™•์ธํ•ด ๋ฐฉ์–ดํ•  ์ˆ˜๋ฐ–์— ์—†์—ˆ๋˜ ๊ฒƒ์ด์ฃ .

ํ”Œ๋žซํผ๋ณ„๋กœ ์‹คํŒจ ์–‘์ƒ๋„ ๋‹ฌ๋ž์Šต๋‹ˆ๋‹ค.

Android: ์ปค๋งจ๋“œ๋ณ„ ๊ฐœ๋ณ„ ๋ฉ”์„œ๋“œ ๋ฐฉ์‹

Android๋Š” ์ปค๋งจ๋“œ๋งˆ๋‹ค ๋ณ„๋„์˜ @JavascriptInterface ๋ฉ”์„œ๋“œ๋กœ ์ •์˜ํ•˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.

internal class InAppBrowserJsInterface(...) : FrommAppJsInterface {
    @JavascriptInterface
    override fun openBrowser(url: String) {
        viewModel.action(InAppBrowserAction.OpenBrowser(url))
    }

    @JavascriptInterface
    override fun openEditPostFanFeed(data: String) {
        val jsonString = JSONObject(data)
        // ... ํŒŒ์‹ฑ ํ›„ ์ฒ˜๋ฆฌ
    }

    // ๊ฐ ์ปค๋งจ๋“œ๋งˆ๋‹ค ๊ฐœ๋ณ„ ๋ฉ”์„œ๋“œ
    // 'newFeature' ๊ฐ™์€ ์ƒˆ ์ปค๋งจ๋“œ ๋ฉ”์„œ๋“œ๋Š” ์กด์žฌํ•˜์ง€ ์•Š์Œ
}

์›น์—์„œ ๋ฏธ์ง€์› ์ปค๋งจ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด, ํ•ด๋‹น ๋ฉ”์„œ๋“œ๊ฐ€ ๋„ค์ดํ‹ฐ๋ธŒ์— ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์›น๋ทฐ์˜ JavaScript์—์„œ TypeError๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ๋„ค์ดํ‹ฐ๋ธŒ ์ชฝ์—๋Š” ํ˜ธ์ถœ ์ž์ฒด๊ฐ€ ๋„๋‹ฌํ•˜์ง€ ์•Š์•„ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š๊ณ , ์›น์—์„œ๋„ ์ด๋ฅผ catchํ•˜๊ณ  ์žˆ์ง€ ์•Š์•„ ๋ฏธ์ง€์›์ธ์ง€ ์ฝ”๋“œ ๋ฒ„๊ทธ์ธ์ง€ ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค.

์ด ๋ฌธ์ œ๋ฅผ ์ธ์‹ํ•˜๊ณ  ์žˆ์—ˆ๊ธฐ์—, ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ปค๋งจ๋“œ์— ๋นˆ ๋ฉ”์„œ๋“œ๋ฅผ ์œ ์ง€ํ•˜๋Š” ๋ฐฉ์–ด ํŒจํ„ด๋„ ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค.

@JavascriptInterface
override fun refreshHome() {
    //do nothing, ์›น์—์„œ ์•„์ง refreshHome ํ˜ธ์ถœ์„ ์ œ๊ฑฐํ•˜์ง€ ์•Š์•„ ์›น์ชฝ ์˜ค๋ฅ˜ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋„๋ก
}

์›น์—์„œ ํ˜ธ์ถœํ•˜๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์—†์œผ๋ฉด JavaScript ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฏ€๋กœ, ์•ฑ์—์„œ ๋นˆ ๋ฉ”์„œ๋“œ๋ฅผ ์œ ์ง€ํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์•ฑ/์›น ๊ฐ„ ๋ฐฐํฌ ์ˆœ์„œ์— ์•”๋ฌต์ ์ธ ์˜์กด์„ ๋งŒ๋“œ๋Š” ๊ฒฐ๊ณผ๋ฅผ ๋‚ณ์•˜์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์›น์—์„œ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ปค๋งจ๋“œ์ผ์ง€๋ผ๋„, ๊ทธ๊ฒƒ์ด ์‚ญ์ œ๋œ ๊ฒƒ์ด ๋ชจ๋ฐ”์ผ ๊ฐœ๋ฐœ์ž๊ฐ€ ์ธ์ง€ํ•˜๊ธฐ๋Š” ์–ด๋ ค์›Œ ๋ฐฉ์น˜๋œ ๋ถ€๋ถ„๋„ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

iOS: ๋‹จ์ผ ์ง„์ž…์  + switch โ†’ default: break

iOS๋Š” WKScriptMessageHandlerWithReply ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์›น์—์„œ postMessage๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด JavaScript Promise๊ฐ€ ๋ฐ˜ํ™˜๋˜๊ณ , iOS๊ฐ€ replyHandler๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๊ทธ Promise๊ฐ€ resolve/reject๋˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.

switch command {
case "isNotificationEnable":
    checkNotificationPermission { isEnabled in
        replyHandler(isEnabled, nil) // Promise resolve
    }
case "openBrowser":
    UIApplication.shared.open(url)
    // replyHandler ํ˜ธ์ถœ ์•ˆ ํ•จ โ†’ Promise pending
default:
    break
    // ๋ฏธ์ง€์› ์ปค๋งจ๋“œ๋„ replyHandler ํ˜ธ์ถœ ์•ˆ ํ•จ โ†’ Promise pending
}

๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ผ๋ถ€ ์ปค๋งจ๋“œ๋งŒ replyHandler๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  ์žˆ์—ˆ๊ณ , ๋‚˜๋จธ์ง€ ์ปค๋งจ๋“œ์™€ ๋ฏธ์ง€์› ์ปค๋งจ๋“œ๋Š” replyHandler๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์•„ Promise๊ฐ€ ์˜์›ํžˆ pending ์ƒํƒœ์— ๋น ์กŒ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ๋Š” ๋Œ€๋ถ€๋ถ„์˜ ์ปค๋งจ๋“œ์—์„œ awaitํ•˜์ง€ ์•Š์•„ ๋‹น์žฅ ๋ฌธ์ œ๊ฐ€ ๋˜์ง„ ์•Š์•˜์ง€๋งŒ, ์›น์—์„œ ๋ฏธ์ง€์› ์—ฌ๋ถ€๋ฅผ ์•Œ ๋ฐฉ๋ฒ•์ด ์ „ํ˜€ ์—†๋Š” ์ƒํƒœ์˜€์Šต๋‹ˆ๋‹ค.

๊ฒฐ๊ตญ, ์•ฑ์ด ๋ฏธ์ง€์› type์— ๋Œ€ํ•ด ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋ฉด ์›น์˜ ๋ฒ„์ „ ๋ถ„๊ธฐ ๋กœ์ง์„ ์ œ๊ฑฐํ•˜๊ณ  ์ผ๊ด€๋œ ์—…๋ฐ์ดํŠธ ํŒ์—… ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•ด์ง‘๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด ์—๋Ÿฌ ํ•ธ๋“ค๋ง ๋ถ€์žฌ๋ฅผ ๊ฐ€์žฅ ๋จผ์ € ํ•ด๊ฒฐํ•˜๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

2. ๋‹จ์ผ ๋ธŒ๋ฆฟ์ง€ ํ•จ์ˆ˜๋กœ์˜ ์ „ํ™˜

๊ธฐ์กด์—๋Š” ์ปค๋งจ๋“œ๋งˆ๋‹ค ๊ฐœ๋ณ„ ํ•จ์ˆ˜/๋ฉ”์„œ๋“œ๋กœ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ๋˜ ๊ตฌ์กฐ๋ฅผ, ํ•˜๋‚˜์˜ ์ง„์ž…์ (sendBridge)์—์„œ type ํ•„๋“œ ๊ฐ’์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ์ปค๋งจ๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ ํ˜•์‹ ์ •์˜

๋จผ์ € ์›น๊ณผ ๋„ค์ดํ‹ฐ๋ธŒ๊ฐ€ ์ฃผ๊ณ ๋ฐ›์„ JSON ํ˜•์‹์„ ์ •์˜ํ–ˆ์Šต๋‹ˆ๋‹ค.

์š”์ฒญ (Web โ†’ Native)

{
  "type": "openBrowser",
  "payload": {
    "url": "https://example.com"
  }
}
  • type (ํ•„์ˆ˜): ์‹คํ–‰ํ•  ์ปค๋งจ๋“œ ์ด๋ฆ„
  • payload (์„ ํƒ): ์ปค๋งจ๋“œ์— ํ•„์š”ํ•œ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ์ฒด

์‘๋‹ต (Native โ†’ Web)

์„ฑ๊ณต ์‹œ:

{
  "success": true,
  "data": { "enabled": true } // optional
}

์‹คํŒจ ์‹œ:

{
  "success": false,
  "error": { "code": "UNKNOWN_TYPE" }
}

success ํ•„๋“œ๋กœ ์„ฑ๊ณต/์‹คํŒจ๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ ๊ตฌ๋ถ„ํ•˜๊ณ , ์‹คํŒจ ์‹œ์—๋Š” ์—๋Ÿฌ ์ฝ”๋“œ๋ฅผ ํ•จ๊ป˜ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—๋Ÿฌ ์ฝ”๋“œ๋Š” ์„ธ ๊ฐ€์ง€๋กœ ๊ตฌ๋ถ„๋ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ์˜๋ฏธ
UNKNOWN_TYPE์•ฑ์ด ํ•ด๋‹น type ์ž์ฒด๋ฅผ ๋ชจ๋ฅด๋Š” ๊ฒฝ์šฐ
UNSUPPORTED_TYPE์•ฑ์€ ์•Œ์ง€๋งŒ ํ˜„์žฌ ํ™”๋ฉด์—์„œ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ•˜๋Š” type์ธ ๊ฒฝ์šฐ
INVALID_PARAMSJSON ํŒŒ์‹ฑ ์‹คํŒจ ๋˜๋Š” ํ•„์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ ๋ˆ„๋ฝ

์›น ์ธก ๊ตฌํ˜„

์ด ๋ฐ์ดํ„ฐ ํ˜•์‹์„ ๋ฐ”ํƒ•์œผ๋กœ, ์›น์—์„œ๋Š” sendBridge๋ผ๋Š” ๋‹จ์ผ ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ๋ชจ๋“  ๋„ค์ดํ‹ฐ๋ธŒ ํ˜ธ์ถœ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

interface BridgeCommandMap {
    openBrowser: (params: { url: string }) => void;
    ...
}


async function sendBridge<T extends keyof BridgeCommandMap>(
    type: T,
    ...args: Parameters<BridgeCommandMap[T]> extends []
        ? []
        : [payload: Parameters<BridgeCommandMap[T]>[0]]
): Promise<BridgeCommandReturnType<T>>

BridgeCommandMap ์ธํ„ฐํŽ˜์ด์Šค์— ๋ชจ๋“  ์ปค๋งจ๋“œ์˜ ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ๋ฐ˜ํ™˜ ํƒ€์ž…์„ ์ •์˜ํ•˜์˜€๊ณ , sendBridge ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœ ํ•  ๋•Œ์—๋Š” type safeํ•˜๊ฒŒ ์ปค๋งจ๋“œ๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ฐ•์ œํ–ˆ์Šต๋‹ˆ๋‹ค.

// ํŒŒ๋ผ๋ฏธํ„ฐ ์—†๋Š” ์ปค๋งจ๋“œ
await sendBridge('pageLoadFinished');

// ํŒŒ๋ผ๋ฏธํ„ฐ ์žˆ๋Š” ์ปค๋งจ๋“œ
await sendBridge('openBrowser', { url: 'https://example.com' });

// ๋ฐ˜ํ™˜๊ฐ’ ์žˆ๋Š” ์ปค๋งจ๋“œ
const isEnabled = await sendBridge('isNotificationEnable');

ํ”Œ๋žซํผ๋ณ„๋กœ ๋„ค์ดํ‹ฐ๋ธŒ์™€์˜ ํ†ต์‹  ๋ฐฉ์‹์ด ๋‹ค๋ฅด์ง€๋งŒ, sendBridge๊ฐ€ ์ด๋ฅผ ๋‚ด๋ถ€์ ์œผ๋กœ ์ถ”์ƒํ™”ํ•ฉ๋‹ˆ๋‹ค.

sendBridge ํ˜ธ์ถœ
    โ”‚
    โ”œโ”€ window.webkit?.messageHandlers?.Fromm โ†’ iOS
    โ”‚       postMessage({ command: 'sendBridge', data: JSON.stringify({ type, payload }) })
    โ”‚
    โ”œโ”€ window.Fromm โ†’ Android
    โ”‚       window.Fromm.sendBridge(JSON.stringify({ type, payload }))
    โ”‚
    โ””โ”€ ๋‘˜ ๋‹ค ์—†์Œ โ†’ ๊ฐœ๋ฐœ/๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ
            console.log ์ถœ๋ ฅ ํ›„ undefined ๋ฐ˜ํ™˜

์‘๋‹ต์ด ์‹คํŒจ์ธ ๊ฒฝ์šฐ BridgeError๋ฅผ throwํ•˜๋ฏ€๋กœ, ํ˜ธ์ถœ๋ถ€์—์„œ ์—๋Ÿฌ๋ฅผ ํ•ธ๋“ค๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ตฌ๋ฒ„์ „ ์•ฑ์—์„œ ์—…๋ฐ์ดํŠธ ํŒ์—… ๋…ธ์ถœ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ, ์›น์—์„œ๋Š” ๋ฒ„์ „ ์ฒดํฌ ํ•  ํ•„์š” ์—†์ดUNKNOWN_TYPE์œผ๋กœ error code๊ฐ€ ๋ฐ˜ํ™˜๋˜๋Š” ๊ฒƒ์„ ๋ณด๊ณ  ๋…ธ์ถœํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

try {
    await sendBridge('download', { url: '...', type: 'image' });
} catch (e) {
    if (e instanceof BridgeError && e.code === 'UNKNOWN_TYPE') {
        showUpdatePopup(); // ๊ตฌ๋ฒ„์ „ ์•ฑ ์—…๋ฐ์ดํŠธ ํŒ์—… ๋…ธ์ถœ
    }
}

3. Android ๊ตฌํ˜„

Android์—์„œ๋Š” sendBridge๋ผ๋Š” ๋‹จ์ผ @JavascriptInterface ๋ฉ”์„œ๋“œ๋งŒ ๋…ธ์ถœํ•˜๊ณ , ๋‚ด๋ถ€์ ์œผ๋กœ ์•„๋ž˜ ๊ทธ๋ฆผ์˜ ํ๋ฆ„์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

์›น๋ทฐ (JSON)
  โ”‚
  โ–ผ
BridgeCommandParser
  โ”‚  JSON์„ ํƒ€์ž… ์•ˆ์ „ํ•œ ์ปค๋งจ๋“œ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜
  โ”‚
  โ”œโ”€ ํŒŒ์‹ฑ ์‹คํŒจ โ†’ BridgeResponse.Error (INVALID_PARAMS / UNKNOWN_TYPE)
  โ”‚
  โ–ผ
BridgeCommand (sealed interface)
  โ”‚  ์•ฑ์ด ์ธ์‹ํ•˜๋Š” ๋ชจ๋“  ๋ธŒ๋ฆฟ์ง€ ๋ช…๋ น์˜ ๊ณตํ†ต ํƒ€์ž…
  โ”‚
  โ”œโ”€ BridgeHandler1 โ”€โ”€ ๋ชจ๋“  ์ปค๋งจ๋“œ ์ฒ˜๋ฆฌ
  โ”œโ”€ BridgeHandler2 โ”€โ”€ ์ผ๋ถ€ ์ปค๋งจ๋“œ๋งŒ ์ฒ˜๋ฆฌ, ๋‚˜๋จธ์ง€๋Š” UNSUPPORTED_TYPE
  โ”‚
  โ–ผ
BridgeResponse
  โ”‚  ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ๋ฅผ toJson()์œผ๋กœ ์ง๋ ฌํ™”ํ•˜์—ฌ ์›น์— ๋ฐ˜ํ™˜
  โ–ผ
์›น๋ทฐ

BridgeCommand โ€” ์ปค๋งจ๋“œ ์ •์˜

์›น์—์„œ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์š”์ฒญํ•  ์ˆ˜ ์žˆ๋Š” ๋ชจ๋“  ๋ช…๋ น์„ sealed interface๋กœ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. Parser๋Š” JSON์„ ์ด ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , Handler๋Š” ์ด ํƒ€์ž…์„ ๋ฐ›์•„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

sealed interface BridgeCommand {
    
    data class OpenBrowser(val url: String) : BridgeCommand
    data class OpenInAppBrowser(val url: String) : BridgeCommand
    ...
}

BridgeCommandParser โ€” Json ํŒŒ์‹ฑ

์›น์—์„œ ๋ณด๋‚ธ JSON ๋ฌธ์ž์—ด์„ BridgeCommand๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ํŒŒ์„œ๋Š” JSON์ด ์œ ํšจํ•œ ์ปค๋งจ๋“œ์ธ์ง€๋งŒ ํŒ๋‹จํ•˜๊ณ , ํ•ด๋‹น ์›น๋ทฐ๊ฐ€ ๊ทธ ์ปค๋งจ๋“œ๋ฅผ ์ง€์›ํ•˜๋Š”์ง€๋Š” ๊ด€์—ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

  • type์„ ์ธ์‹ํ•˜์ง€ ๋ชปํ•˜๋ฉด โ†’ UNKNOWN_TYPE
  • JSON ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ๊ฑฐ๋‚˜ ํ•„์ˆ˜ ํ•„๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜๋ฉด โ†’ INVALID_PARAMS
  • ์ •์ƒ ํŒŒ์‹ฑ โ†’ BridgeCommand ๊ฐ์ฒด ์ƒ์„ฑ

BridgeHandler โ€” ์›น๋ทฐ ๋‹จ์œ„์˜ ์ปค๋งจ๋“œ ๋™์ž‘ ์ •์˜

ํŒŒ์‹ฑ๋œ BridgeCommand๋ฅผ ์‹ค์ œ๋กœ ์‹คํ–‰ํ•˜๋Š” ์ฃผ์ฒด์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ๊ณ ๋ คํ•œ ์ ์€, ๋„ค์ดํ‹ฐ๋ธŒ ์›น๋ทฐ ํด๋ž˜์Šค๋งˆ๋‹ค ๊ตฌํ˜„ํ•˜๋Š” ์ปค๋งจ๋“œ ๋ฒ”์œ„๊ฐ€ ๋‹ค๋ฅด๋‹ค๋Š” ์ ์ž…๋‹ˆ๋‹ค. ํ”ผ๋“œ, ๋ผ์ด๋ธŒ, ๋ฏธ๋””์–ด ๋“ฑ ๋ชจ๋“  ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์›น๋ทฐ ํด๋ž˜์Šค๊ฐ€ ์žˆ๋Š” ๋ฐ˜๋ฉด, ์„ค์ •ยท๊ณต์ง€์‚ฌํ•ญ ๋“ฑ ๋‹จ์ˆœ ์ฝ˜ํ…์ธ ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๊ณต์šฉ ์›น๋ทฐ ํด๋ž˜์Šค๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด download๋Š” ํŒŒ์„œ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ํŒŒ์‹ฑํ•˜์ง€๋งŒ, ํ•ด๋‹น ์ปค๋งจ๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์€ ์›น๋ทฐ ํด๋ž˜์Šค์˜ ํ•ธ๋“ค๋Ÿฌ์—์„œ๋Š” UNSUPPORTED_TYPE์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด์ฒ˜๋Ÿผ ๋™์ผํ•œ ์ปค๋งจ๋“œ๋ผ๋„ ์–ด๋А ์›น๋ทฐ ํด๋ž˜์Šค์—์„œ ์‹คํ–‰๋˜๋А๋ƒ์— ๋”ฐ๋ผ ๊ฒฐ๊ณผ๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์–ด UNSUPPORTED_TYPE ํƒ€์ž…์ด ์ƒ๊ฒจ๋‚œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.


internal class WebviewBridgeHandler(
    ...
) {
    fun handle(command: BridgeCommand): BridgeResponse = when (command) {

        is BridgeCommand.OpenBrowser -> {
            // ๋ธŒ๋ผ์šฐ์ € ์—ด๊ธฐ ๋™์ž‘
            BridgeResponse.Success()
        }

        is BridgeCommand.OpenInAppBrowser -> {
            // ์ธ์•ฑ ๋ธŒ๋ผ์šฐ์ € ์—ด๊ธฐ ๋™์ž‘
            BridgeResponse.Success()
        }
        
        // BridgeCommand.Download ๋“ฑ ๋ฏธ์ง€์› type์˜ ๊ฒฝ์šฐ
        else -> BridgeResponse.Error(BridgeErrorCode.UNSUPPORTED_TYPE)
}

BridgeResponse

๋ชจ๋“  ํ•ธ๋“ค๋Ÿฌ๋Š” ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ๋ฅผ BridgeResponse๋กœ ๋ฐ˜ํ™˜ํ•˜๋ฉฐ, toJson()์„ ํ†ตํ•ด JSON ๋ฌธ์ž์—ด๋กœ ์ง๋ ฌํ™”ํ•˜์—ฌ ์›น๋ทฐ์— ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

sealed class BridgeResponse {

    abstract fun toJson(): String

    data class Success(val data: Any? = null) : BridgeResponse() {
        override fun toJson(): String = JSONObject().apply {
            put("success", true)
            if (data != null) {
                when (data) {
                    is JSONObject -> put("data", data)
                    is JSONArray -> put("data", data)
                    is List<*> -> put("data", JSONArray(data))
                    is Map<*, *> -> put("data", JSONObject(data))
                    else -> put("data", data)
                }
            }
        }.toString()
    }

    data class Error(val code: BridgeErrorCode) : BridgeResponse() {
        override fun toJson(): String = JSONObject().apply {
            put("success", false)
            put(
                "error",
                JSONObject().apply {
                    put("code", code.value)
                },
            )
        }.toString()
    }
}

4. ๊ฐœ์„  ํšจ๊ณผ ์ •๋ฆฌ

๋ฒ„์ „ ๋ถ„๊ธฐ ๋กœ์ง ์ œ๊ฑฐ

์•ฑ์ด ๋ฏธ์ง€์› type์— ๋Œ€ํ•ด ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜๋ฉด์„œ, ์›น์— ์‚ฐ์žฌํ•˜๋˜ checkAppVersion ๋ถ„๊ธฐ ๋กœ์ง์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ๋งค ์—…๋ฐ์ดํŠธ ๋งˆ๋‹ค ๋ฒ„์ „์„ ์ฒดํฌํ•  ํ•„์š” ์—†์ด ๋ฏธ์ง€์› ์‹œ ์ผ๊ด€๋œ ์—…๋ฐ์ดํŠธ ํŒ์—… ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•ด์กŒ์Šต๋‹ˆ๋‹ค.

๋””๋ฒ„๊น… ์šฉ์ด์„ฑ ํ–ฅ์ƒ

๊ธฐ์กด์—๋Š” ๋ฏธ์ง€์› ์ปค๋งจ๋“œ ํ˜ธ์ถœ ์‹œ ์•„๋ฌด๋Ÿฐ ์‘๋‹ต ์—†์ด ์‹คํŒจํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ์›์ธ์„ ํŒŒ์•…ํ•˜๊ธฐ ์–ด๋ ค์› ์Šต๋‹ˆ๋‹ค. ์ด์ œ๋Š” ์•ฑ์ด UNKNOWN_TYPE, UNSUPPORTED_TYPE, INVALID_PARAMS ๋“ฑ ๊ตฌ์ฒด์ ์ธ ์—๋Ÿฌ ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ, ์›น์—์„œ ์–ด๋–ค ์ด์œ ๋กœ ์‹คํŒจํ–ˆ๋Š”์ง€ ๋ฐ”๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด ๋””๋ฒ„๊น…์ด ํ›จ์”ฌ ์ˆ˜์›”ํ•ด์กŒ์Šต๋‹ˆ๋‹ค.

์ปค๋งจ๋“œ ๋ณ€๊ฒฝ์— ์œ ์—ฐํ•œ ๊ตฌ์กฐ

๊ธฐ์กด์—๋Š” ์ปค๋งจ๋“œ์— ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ถ”๊ฐ€๋˜๊ฑฐ๋‚˜ ์‚ญ์ œ๋  ๋•Œ, ์›น์—์„œ ๋ฒ„์ „๋ณ„๋กœ ๋‹ค๋ฅธ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๋ถ„๊ธฐ๊ฐ€ ํ•„์š”ํ–ˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ํŠน์ • ์ปค๋งจ๋“œ์— ์ƒˆ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ถ”๊ฐ€๋˜๋ฉด, ํ•ด๋‹น ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ชจ๋ฅด๋Š” ๊ตฌ๋ฒ„์ „ ์•ฑ์„ ์œ„ํ•ด ๋ฒ„์ „๋ณ„๋กœ ๋ณด๋‚ด๋Š” JSON์„ ๋‹ฌ๋ฆฌํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ด์ œ๋Š” ์›น์—์„œ ํ•ญ์ƒ ์ตœ์‹  ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํฌํ•จํ•œ JSON์„ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์•ฑ์˜ ํŒŒ์„œ๊ฐ€ ํ•„์ˆ˜/์„ ํƒ ํ•„๋“œ๋ฅผ ๊ตฌ๋ถ„ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ, ๊ตฌ๋ฒ„์ „ ์•ฑ์€ ๋ชจ๋ฅด๋Š” ํ•„๋“œ๋ฅผ ๋ฌด์‹œํ•˜๊ณ  ์‹ ๋ฒ„์ „ ์•ฑ์€ ์ƒˆ ํ•„๋“œ๋ฅผ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ƒˆ ์ปค๋งจ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒฝ์šฐ์—๋„ ๊ตฌ๋ฒ„์ „ ์•ฑ์€ UNKNOWN_TYPE์„ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ, ์›น์—์„œ ๊ตฌ๋ฒ„์ „ ์•ฑ์— ๋Œ€ํ•œ ๋ฒ„์ „ ์ฒดํฌ๊ฐ€ ํ•„์š” ์—†์–ด์กŒ์Šต๋‹ˆ๋‹ค.

๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์ด๋ฒˆ ์ž‘์—…์—์„œ๋Š” Android ๊ตฌํ˜„๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์›น ์ธก ์„ค๊ณ„์™€ ๊ตฌํ˜„๋„ ํ•จ๊ป˜ ๋งก์•˜์Šต๋‹ˆ๋‹ค. ํ‰์†Œ์—๋Š” ๋„ค์ดํ‹ฐ๋ธŒ ๊ด€์ ์—์„œ๋งŒ ๋ธŒ๋ฆฟ์ง€๋ฅผ ๋ฐ”๋ผ๋ดค๋Š”๋ฐ, ์›น์˜ ์ž…์žฅ์ด ๋˜์–ด๋ณด๋‹ˆ ๊ธฐ์กด ๊ตฌ์กฐ์˜ ๋ถˆํŽธํ•จ์ด ๋” ์‹ค๊ฐ๋‚ฌ์Šต๋‹ˆ๋‹ค. ๋ฒ„์ „ ๋ถ„๊ธฐ๋ฅผ ์™œ ์ด๋ ‡๊ฒŒ ๋งŽ์ด ๋„ฃ์„ ์ˆ˜๋ฐ–์— ์—†์—ˆ๋Š”์ง€, ์—๋Ÿฌ ์‘๋‹ต์ด ์—†๋‹ค๋Š” ๊ฒŒ ํ˜ธ์ถœํ•˜๋Š” ์ชฝ์—์„œ ์–ผ๋งˆ๋‚˜ ๋‹ต๋‹ตํ•œ ์ผ์ธ์ง€ ์ง์ ‘ ๊ฒช์–ด๋ณด๋‹ˆ ํ™• ์™€๋‹ฟ์•˜์Šต๋‹ˆ๋‹ค.

์–‘์ชฝ์„ ๋ชจ๋‘ ๊ณ ๋ คํ•œ ๋•๋ถ„์— โ€œ์•ฑ์ด ๋ชจ๋ฅด๋Š” ์ปค๋งจ๋“œ์— ๋Œ€ํ•ด ๋ช…ํ™•ํ•˜๊ฒŒ ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹คโ€๋Š” ์›์น™์„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์„ธ์šธ ์ˆ˜ ์žˆ์—ˆ๊ณ , ๊ทธ ์›์น™ ํ•˜๋‚˜๋กœ ๋ฒ„์ „ ๋ถ„๊ธฐ ์ œ๊ฑฐ, ๋ฐฐํฌ ์ˆœ์„œ ์˜์กด์„ฑ ํ•ด์†Œ๊นŒ์ง€ ์ด์–ด์งˆ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์š”์ฒญ/์‘๋‹ต ํ˜•์‹์ด ํ†ต์ผ๋˜๊ณ  ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋„ ๊ทœ๊ฒฉํ™”๋˜๋ฉด์„œ, ์›น-๋„ค์ดํ‹ฐ๋ธŒ ๊ฐ„ ํ˜‘์—… ๊ณผ์ •์—์„œ์˜ ์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜ ๋น„์šฉ์ด ์ค„์–ด๋“œ๋Š” ๊ฒƒ ๋˜ํ•œ ๊ธฐ๋Œ€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

โ† ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ

Art Changes Life

๋…ธ๋จธ์Šค์™€ ํ•จ๊ป˜ ์—”ํ„ฐํ…Œํฌ ์‚ฐ์—…์„ ํ˜์‹ ํ•ด๋‚˜๊ฐˆ ๋ฉค๋ฒ„๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค.

์ฑ„์šฉ ์ค‘์ธ ๊ณต๊ณ  ๋ณด๊ธฐ