
저희가 운영하는 마켓 서비스 중 하나는 네이티브 앱의 WebView 안에서만 정상 동작하도록 설계되어 있습니다. 이모티콘 다운로드 같은 핵심 기능이 앱이 주입하는 자바스크립트 인터페이스(JS-Native Bridge)를 거치도록 되어 있어서, 인터페이스가 없는 환경에서는 핵심 기능 대부분이 동작하지 않는 사이트가 됩니다.
저희는 사용자가 앱 인터페이스를 호출하는 시점마다 어떤 인터페이스를(downloadEmoticon, signIn 등) 호출했고, 그 호출이 정상적으로 이뤄졌는지를 userAgent와 함께 서버 로그로 남기도록 해두었습니다. 앱이 주입한 인터페이스 자체가 없는 환경에서는 호출이 no-interface 상태로 기록됩니다. 로그를 살펴보니 KakaoTalk 인앱 브라우저, Samsung Internet, Instagram 인앱 브라우저, 일반 데스크톱 Chrome/Safari, 그리고 userAgent에는 wv 표식이 있지만 다운로드 인터페이스 호출이 no-interface으로 떨어지는 비정상 WebView까지, 인터페이스가 없는 채로 페이지에 진입하는 사례가 꾸준히 잡히고 있었습니다. 이 사용자들은 다운로드 버튼을 눌러도 아무 반응이 없는 화면을 보고 그대로 이탈하고 있었습니다.
이 글에서는 인터페이스 부재를 어떻게 감지하고, 환경별로 어떻게 다른 안내를 보여줄지 정리하면서 단계적으로 안내 플로우를 만든 과정을 정리합니다.
폴링으로 인터페이스 감지하기
가장 먼저 정한 것은 "앱 환경"을 무엇으로 판단할지였습니다. UserAgent의 wv 표식은 KakaoTalk·Instagram 같은 일반 인앱 브라우저에도 들어 있어 신뢰할 수 없었고, 비정상 WebView에서는 표식이 있어도 인터페이스가 빠져 있는 케이스가 있었습니다. 결국 가장 실용적인 기준은 "앱이 주입한 인터페이스가 실제로 호출 가능한 상태인가"였습니다.
저희는 앱과 약속된 다섯 개 메서드(downloadEmoticon, getDownloadedEmoticonIds, signIn, signOut, close)가 모두 존재할 때만 앱 환경으로 간주하기로 했습니다.
// src/lib/nativeUtils.ts
const REQUIRED_BRIDGE_KEYS = [
"downloadEmoticon",
"getDownloadedEmoticonIds",
"signIn",
"signOut",
"close",
] as const;
export const hasNativeInterface = () => {
if (typeof window === "undefined") return false;
const bridge = window.WebviewInterface;
if (!bridge) return false;
return REQUIRED_BRIDGE_KEYS.every((key) => typeof bridge[key] === "function");
};
export const isAndroid = () =>
typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent);hasNativeInterface()만으로 판단하면 한 가지 문제가 남았습니다. 로그인 인터페이스(signIn)는 페이지 로드 직후가 아니라 약간의 지연을 두고 주입되는 경우가 있어, 페이지 진입 즉시 검사하면 정상 앱 사용자에게도 안내 모달이 잠깐 노출될 수 있었습니다. 초기 구현은 1.5초 고정 setTimeout 뒤에 검사하는 방식이었지만, 기기 성능에 따라 1.5초가 짧거나 긴 경우가 모두 있어 안정적이지 않았습니다.
그래서 고정 지연 대신, 인터페이스가 들어올 때까지 짧은 간격으로 반복 확인하는 폴링 유틸리티를 도입했습니다.
// src/lib/utils/pollUntil.ts
type Options = { interval: number; maxAttempts: number };
export async function pollUntil(
predicate: () => boolean,
{ interval, maxAttempts }: Options,
): Promise<boolean> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (predicate()) return true;
await new Promise((resolve) => setTimeout(resolve, interval));
}
return predicate();
}진입 시점에는 pollUntil(hasNativeInterface, { interval: 200, maxAttempts: 10 })로 최대 1.8초 동안 인터페이스 주입을 감지합니다. 정상 앱 환경이라면 보통 첫 1~2회 안에 통과하기 때문에, 사용자에게 추가 지연이 거의 느껴지지 않으면서도 늦게 들어오는 인터페이스에 대한 여유를 확보할 수 있었습니다.
200ms × 10회라는 값은 앱 측 signIn 인터페이스가 가지고 있는 자체 재호출 로직(최대 1초, 50ms × 20회)을 기준으로 잡았습니다. 인터페이스 주입이 살짝 늦어지는 케이스는 대부분 그 1초 안에 안정화되고, 그 이후로 들어오는 경우를 위해 약간의 여유를 더 둔 것이 1.8초입니다. 폴링이 한 번 통과하면 즉시 다음 단계로 진행되기 때문에 정상 앱 환경에서는 사용자에게 추가 지연이 거의 느껴지지 않습니다.
다만 저사양 기기처럼 주입이 더 늦어지는 케이스에서는 1.8초로도 부족할 여지가 남아 있습니다. 이 부분은 일정 간격 폴링 대신 지수 백오프(예: 100ms, 200ms, 400ms, 800ms ...)로 바꾸어 초반에는 더 자주 확인하고 후반에는 간격을 늘려가는 방식으로 정리하면, 동일한 총 대기 시간 안에서도 늦게 들어오는 인터페이스를 더 안정적으로 흡수할 수 있을 것으로 보고 있습니다.
환경 분기: Android는 딥링크, 그 외는 안내만
폴링이 끝났는데도 인터페이스가 없으면, 사용자가 어떤 환경에 있느냐에 따라 보여줄 화면이 달라야 했습니다. Android 사용자라면 저희 앱이 설치돼 있을 가능성이 있어 앱으로 유도할 수 있지만, iOS 사용자나 데스크톱 사용자에게 같은 메시지를 띄우면 클릭해도 동작하지 않는 버튼이 됩니다.
실제로 저희가 운영하는 앱은 현재 Android 전용이기 때문에, iOS·데스크톱 사용자에게는 "Android 앱에서만 이용할 수 있다"는 사실 자체를 안내하는 것이 정확합니다. 그래서 BottomSheet 옵션을 두 가지로 분리했습니다.
// src/lib/noInterfaceModalOptions.tsx
export const getNoInterfaceModalOptions = () => ({
type: "action-1btn" as const,
title: "앱에서 이용해주세요",
message: "앱에서 열면 보다 원활한 서비스 이용이 가능해요.",
confirmLabel: "앱에서 열기",
onConfirm: () => openAppFromWeb(),
closeOnOverlayClick: true,
});
export const getNonAndroidModalOptions = () => ({
type: "action-1btn" as const,
title: "안내",
message: "해당 서비스는 안드로이드 앱에서만 이용할 수 있어요.",
confirmLabel: "확인",
closeOnOverlayClick: true,
});진입 시점의 분기는 단순합니다.
// src/components/NativeInterfaceGuard.tsx (요약)
useEffect(() => {
let cancelled = false;
(async () => {
const ok = await pollUntil(hasNativeInterface, {
interval: 200,
maxAttempts: 10,
});
if (cancelled || ok) return;
if (sessionStorage.getItem(NO_INTERFACE_SHOWN_KEY)) return;
const options = isAndroid()
? getNoInterfaceModalOptions()
: getNonAndroidModalOptions();
openModal("noInterfaceModal", options);
})();
return () => {
cancelled = true;
};
}, []);폴링 결과가 실패이고, 같은 세션에서 아직 안내를 보여주지 않았다면 환경에 맞는 BottomSheet를 띄우는 흐름입니다.
BottomSheet과 sessionStorage로 세션 단위 안내
안내를 띄우는 형태로 일반 Modal과 BottomSheet 중 어느 쪽을 쓸지 먼저 정해야 했습니다. 진입 안내는 사용자가 페이지를 충분히 살펴본 뒤에 보여주는 메시지가 아니라, 첫 진입 시점에 곧바로 표시되는 안내입니다. 화면 가운데에서 콘텐츠를 가리는 일반 Modal보다는, 화면 하단에서 슬라이드 업 되는 BottomSheet가 모바일 환경에서 좀 더 자연스러울 것이라 보고 BottomSheet 쪽으로 정리했습니다. 사용자가 안내를 닫지 않은 상태에서도 페이지 상단의 정보를 일부 살펴볼 수 있다는 점도 함께 고려했습니다.
초기에는 "오늘 하루 보지 않기" 옵션을 두고 localStorage에 24시간 동안 숨겨두는 방식이었습니다. 의도는 안내 피로도를 낮추는 것이었는데, 운영하면서 두 가지 문제가 드러났습니다. 외부 브라우저에서 우연히 한 번 페이지를 연 사용자가 24시간 동안 안내를 다시 보지 않게 되어, 며칠 뒤에 다시 들어왔을 때 빈 페이지처럼 느끼게 되었습니다. 또 "오늘 그만보기" 버튼을 추가해 모달을 2버튼 구성으로 만들자, 본래의 행동 유도("앱에서 열기")가 시각적으로 묻혀 보였습니다.
그래서 모달을 1버튼 구성으로 단순화하고, 노출 제어 단위를 sessionStorage로 옮겼습니다. 같은 탭 안에서 새로고침하거나 페이지를 이동해도 다시 뜨지 않지만, 탭을 닫고 다시 열면 안내가 다시 표시되는 방식입니다. 닫힘 경로(버튼 클릭, 오버레이 클릭, ESC)에 관계없이 동일하게 기록됩니다.
useEffect(() => {
if (noInterfaceModal.isOpen) return;
if (!noInterfaceModal.hasBeenShownInThisRender) return;
sessionStorage.setItem(NO_INTERFACE_SHOWN_KEY, "1");
}, [noInterfaceModal.isOpen]);여기에 BottomSheet 자체의 닫힘 애니메이션을 정리한 부분이 함께 들어갔습니다. 초기 구현은 컨테이너가 isOpen: false가 되는 즉시 컴포넌트를 언마운트했고, 그 결과 슬라이드 다운 트랜지션이 화면에 보이지 않았습니다. 닫힘 애니메이션이 실행될 시점에 이미 DOM에서 사라지고 있었기 때문입니다.
이를 해결하기 위해 BottomSheet 내부에서 닫힘 의도를 한 번 받아 300ms 동안 트랜지션을 진행한 뒤, 그 다음에 store의 closeModal을 호출하도록 흐름을 바꿨습니다.
function BottomSheet({ onClose, ...rest }: Props) {
const [isClosing, setIsClosing] = useState(false);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => onClose(), 300);
};
return (
<div
className={cn(styles.sheet, { [styles.closing]: isClosing })}
role="dialog"
>
{/* ... */}
</div>
);
}onConfirm/onCancel 콜백에서는 부가 동작(앱 열기 등)만 수행하고, 모달 닫기는 BottomSheet의 handleClose에 위임합니다. 호출자 입장에서 닫힘 처리를 두 번 신경 쓰지 않아도 되고, 트랜지션이 자연스럽게 보이게 되었습니다.
여기에 작은 옵션 하나를 더 두었습니다. BottomSheet 위 영역(오버레이)을 클릭했을 때 닫히게 할지를 모달마다 다르게 가져갈 필요가 있었기 때문입니다. 진입 시점의 안내는 사용자가 가볍게 닫고 페이지를 둘러볼 수 있어야 한다고 보고 오버레이 클릭으로 닫히게 두었지만, 다른 흐름에서는 명시적인 버튼 액션만으로 닫혀야 하는 경우도 있었습니다. 이를 모달 단위로 제어할 수 있도록 store에 옵션을 추가했습니다.
// src/store/useModalStore.ts
type ModalOptions = {
// ...
closeOnOverlayClick?: boolean;
};컨테이너 컴포넌트는 이 값을 받아 백드롭 클릭 핸들러를 분기합니다. 진입 안내처럼 오버레이 클릭 닫힘이 자연스러운 곳에서는 true를 명시적으로 넘기고, 그 외 모달은 옵션을 생략하거나 false를 넘기는 형태로 호출 단에서 행동을 명확히 하도록 두었습니다.
다운로드 시점의 추가 안내
전역 BottomSheet만으로는 한 가지 케이스를 잡지 못합니다. 사용자가 진입 안내를 닫고 페이지를 둘러본 뒤에 다운로드 버튼을 누르면, 인터페이스가 없는 환경이라 동작이 일어나지 않은 채 그대로 끝나버립니다. 이 시점에 한 번 더 안내가 필요했습니다.
버튼 노출 조건 정리
먼저 정리한 것은 버튼 자체의 노출/활성화 조건이었습니다. 기존에는 isInApp 값을 기준으로 웹 환경에서는 버튼을 아예 숨기거나 비활성화하고 있었는데, 이렇게 두면 사용자는 "다운로드 기능이 없는 페이지"로 착각할 수밖에 없습니다. 동시에 "왜 다운로드가 안 되는가"를 안내할 진입점도 사라지기 때문에, 사용자에게 다음 단계를 알려줄 방법 자체가 없어집니다.
구매 목록 카드(PurchaseCard)의 변경 전 조건은 다음과 같았습니다.
// Before: PurchaseCard
{statusType !== "EXPIRED" && (baseDownloadable || isDownloading) && (
<DownloadButton ... />
)}
// baseDownloadable = isInApp && !isDownloadedisInApp이 false인 외부 브라우저에서는 baseDownloadable이 항상 false이기 때문에 버튼이 렌더링되지 않습니다. 변경 후에는 환경 조건을 빼고, 콘텐츠 자체의 상태(스티커 ID 존재, 미다운로드, 만료 여부)만으로 버튼을 결정하도록 바꿨습니다.
// After: PurchaseCard
{statusType !== "EXPIRED" && emoticonId && !isDownloaded && (
<DownloadButton onClick={onClickDownload} ... />
)}선물함 카드(ReceivedGiftCard)는 다운로드 진행 중 상태도 화면에 표시해야 해서 조건이 한 단계 더 들어갑니다. 다운로드가 시작되면 버튼 자리에 로딩 아이콘을 함께 보여주고, 완료되면 버튼이 사라지는 흐름입니다.
// After: ReceivedGiftCard
{emoticonId && (!downloadDisabled || isDownloading) && (
<DownloadButton onClick={onClickDownload} ... />
)}
// downloadDisabled = isDownloaded || (isAnyDownloading && !isDownloading)두 컴포넌트는 다운로드 중 상태를 표시할지 여부가 갈리지만, 환경 체크(isInApp)를 제거하고 콘텐츠의 상태만으로 버튼을 결정한다는 공통 규칙은 동일하게 적용했습니다. 다운로드 완료 이후 버튼이 사라지는 동작도 그대로 유지되어, 정상 앱 사용자 입장에서 보이는 화면은 변하지 않습니다.
클릭 시점의 인터페이스 재확인
버튼이 항상 노출되기 때문에, 외부 브라우저에서 클릭이 들어오는 케이스를 클릭 핸들러에서 처리해야 합니다. 진입 시점의 폴링은 통과했더라도 그 사이에 인터페이스가 사라질 수 있고, 진입 안내를 한 번도 받지 못한 사용자가 다운로드 버튼부터 누르는 흐름도 있기 때문에, 클릭 시점에 인터페이스를 한 번 더 확인합니다.
const onClickDownload = () => {
if (!hasNativeInterface()) {
openModal(
"downloadModal",
isAndroid() ? getAppMoveModalOptions() : getNonAndroidModalOptions(),
);
return;
}
startDownload(emoticonId);
};같은 환경 분기를 다운로드 모달에도 그대로 적용했습니다. Android 사용자에게는 "앱으로 이동" 버튼으로 딥링크를 호출하고, 그 외 환경에서는 "확인" 버튼만 두어 모달을 닫습니다. 진입 시점과 다운로드 시점에 같은 종류의 안내가 두 번 등장하긴 하지만, 첫 안내를 닫은 뒤 둘러보다가 다운로드를 시도하는 흐름까지 끊기지 않게 받쳐주는 쪽을 우선했습니다.
Android 딥링크와 폴백
"앱에서 열기"에 연결되는 딥링크는 현재 페이지 URL을 함께 들고 앱으로 넘기도록 만들었습니다. 사용자가 마켓의 특정 상세 페이지를 외부 브라우저에서 보고 있었다면, 앱이 열렸을 때 같은 상세 페이지로 들어오는 것이 자연스럽기 때문입니다. 아래 코드는 실제 스킴/패키지명을 가린 형태로, 일반적인 Android Intent URI의 구조를 보여줍니다.
// src/lib/deeplink.ts
const APP_FALLBACK_URL = "https://store.example.com/app/...";
export const openAppFromWeb = () => {
const returnUrl = window.location.href;
const intentUrl =
`intent://callback?returnUrl=${encodeURIComponent(returnUrl)}` +
`#Intent;scheme=myapp;package=com.example.app;` +
`S.browser_fallback_url=${encodeURIComponent(APP_FALLBACK_URL)};end;`;
window.location.href = intentUrl;
};S.browser_fallback_url은 앱이 설치돼 있지 않을 때 이동할 페이지로, 저희 환경에서는 스토어의 앱 페이지를 지정해두었습니다. 앱이 있다면 인텐트 처리로 자연스럽게 앱이 뜨고, 없다면 스토어 페이지로 흐르는 형태입니다. URL 파라미터에는 한글이나 쿼리 문자열이 섞일 수 있어 encodeURIComponent로 한 번 감쌌습니다.
웹에서 임의의 URL을 파라미터로 실어 보내는 구조이기 때문에, 받아주는 앱 측에서 이 값을 그대로 WebView에 로드하면 외부에서 위장된 딥링크로 사용자를 유도해 신뢰된 앱 컨텍스트 안에 임의 페이지를 띄우는 경로가 생깁니다. 앱에서는 returnUrl(또는 그에 해당하는 파라미터)에 들어온 도메인을 화이트리스트로 검증한 뒤에만 WebView에 로드하도록 두는 편이 안전합니다.
사용자 경험 흐름 정리
세 가지 환경에서의 흐름을 마지막으로 정리하면 다음과 같습니다.
| 환경 | 진입 직후 | 다운로드 버튼 클릭 |
|---|---|---|
| 저희 앱 | BottomSheet 미표시 | 정상 다운로드 |
| 외부 브라우저 (Android) | 폴링 후 "앱에서 열기" BottomSheet | "앱으로 이동" 모달 |
| 외부 브라우저 (iOS·데스크톱 등) | "Android 앱에서만 이용 가능" BottomSheet | "확인" 모달 |
같은 세션 안에서 BottomSheet는 한 번만 노출되고, 다운로드 모달은 클릭마다 노출됩니다. 안내가 사용자의 현재 행동과 직접 연결되도록 두 시점을 분리한 결과입니다.
남은 과제
이번 작업은 인터페이스 부재라는 증상에 대한 방어선을 두른 것에 가깝고, 근본 원인이 모두 해결된 상태는 아닙니다. 살펴보면서 남겨둔 항목들을 정리해 둡니다.
첫째, 정상 앱 사용자에게도 인터페이스가 주입되지 않는 케이스가 일부 로그에서 발견되었습니다. userAgent에는 wv가 있지만 인터페이스 호출이 no-interface으로 떨어지거나, 정상적으로 진입한 사용자가 페이지를 사용하던 중 갑자기 인터페이스가 사라지는 패턴입니다. 이는 WebView 초기화 시점이나 브리지 주입 타이밍과 관련된 네이티브 영역의 문제일 가능성이 있어, 앱 팀과 함께 디바이스/OS 버전을 좁혀가며 살펴볼 항목으로 남겨두었습니다.
둘째, 현재 앱 환경 판별이 WebviewInterface의 메서드 존재 여부에만 의존하고 있어, 약속된 키가 어떤 이유로든 누락되면 정상 앱이 외부 환경처럼 분류될 수 있습니다. 네이티브 앱 측에 MyApp/{version} 형태의 UserAgent 식별자를 추가하면 KakaoTalk 같은 타사 인앱 브라우저와 명확히 구분되고, 인터페이스 주입 여부와 별개로 환경 자체를 식별할 수 있어 안정성이 한 단계 올라갑니다.
셋째, "앱에서 열기" BottomSheet와 다운로드 모달이 연속으로 노출되는 케이스가 여전히 있습니다. 두 시점을 묶어 한 번에 처리하면 사용 흐름이 끊기고, 분리해두면 안내가 두 번 보이는 구조라 이번에는 후자를 선택했습니다. 다만 첫 안내에서 사용자가 "앱에서 열기"를 누르고 앱 전환에 실패한 경우에는 두 번째 모달이 의미를 잃기 때문에, 딥링크 호출 결과를 감지해 두 번째 안내를 생략하는 방식 등으로 다듬을 여지가 있습니다.
정리
진입 시점에 한 번, 행동 시점에 한 번, 두 번의 안내로 외부 브라우저 사용자가 빈 화면을 마주하는 흐름은 정리되었습니다. 폴링으로 인터페이스 주입 타이밍을 흡수하고, sessionStorage 단위로 노출을 제어하고, 플랫폼 분기로 동작 가능한 안내만 보여주는 작은 결정들이 합쳐진 결과입니다.
기능은 대개 정상 동작 환경을 기준으로 설계하지만, 실제 운영에서는 그 바깥 경로로 들어오는 사용자가 늘 존재합니다. 이번 작업은 그런 사용자에게도 빈 화면 대신 다음 행동을 안내하도록 보완한 것입니다. 앞으로는 "이 기능이 동작하지 않는 환경에서 무엇을 보여줄 것인가"를 설계 단계에서부터 함께 점검 하겠습니다.
Related Posts

결제 완료 후 뒤로가기 문제 해결하기
결제 완료 후 뒤로가기 시 결제 대행사 페이지로 돌아가는 문제를 해결하며, 브라우저 히스토리 관리와 라우팅 메커니즘을 이해하고 사용자 인터랙션을 활용한 해결 과정을 정리합니다.

Lighthouse 접근성 점수가 움직이지 않은 이유
Lighthouse 자동 검사가 잡는 영역과 잡지 않는 영역을 구분하고, 마크업 4건을 정리해 접근성 점수를 84점에서 95점까지 올린 과정을 다룹니다.

Next.js GitHub Pages 배포 트러블슈팅: export, basePath, configure-pages
GitHub Pages에 Next.js를 정적 export로 배포하면서 겪은 문제점과 디버그/해결 과정을 정리했습니다.