
웹뷰 기반 프로젝트에서 토스페이먼츠 결제를 연동하던 중 결제 앱으로 이어지지 않는 문제를 마주했다.
토스페이먼츠나 일반 카드 결제처럼 일부 결제 수단은 외부 앱 호출과 결제 완료 후 복귀 흐름이 정상적으로 작동했다. 하지만 카카오페이나 일부 결제는 웹뷰에서 앱으로 넘어가지 않는 현상이 발생했다.
동일한 결제 플로우임에도 특정 결제 수단에서만 오류가 발생한다는 점은 원인을 파악하기 어렵게 만들었다. 이번 글에서는 이 문제를 어떻게 분석하고 해결했는지 그 과정을 정리해 보려 한다.
웹뷰 환경에서 토스페이먼츠를 연동하려면 앱스킴을 반드시 등록해야 한다. 결제 과정에서 외부 앱(토스, 카카오페이, 카드사 앱 등)을 호출한 뒤 다시 웹뷰로 돌아오는 흐름이 필요하기 때문이다. 본격적인 이야기를 하기 전에, 앱스킴이 어떤 개념인지 간단하게 짚고 넘어가 보겠다.
앱스킴은 모바일 환경에서 특정 앱을 실행시키거나 앱 내부의 특정 화면으로 직접 이동하기 위해 사용되는 URL 규칙이다. 웹에서 http:// 나 https:// 로 웹페이지를 여는 것처럼 앱에서는 myapp:// 과 같은 스킴을 통해 앱을 호출한다. 이러한 구조는 딥링크 방식 중 하나로 모바일 서비스 간의 이동이나 인증, 결제 플로우에서 널리 사용된다.
보다 자세한 내용은 아래 문서를 참고하면 이해하는 데 도움이 된다.
웹뷰에서 결제를 위해 토스페이먼츠 결제창을 띄운 뒤 카카오페이를 선택하면 결제 진행 버튼을 눌러도 앱이 실행되지 않고 대신 아래와 같은 오류가 발생했다.
Can't open url: intent://kakaopay/pg?payweb_talk_min_version=11.3.0&payweb_url=https%3A%2F%2Fonline-payment.kakaopay.com%2Fpay%2Fmobile-web%2Freseller%2Fone-time%2Fpayment%2F7beb59e892431fe2d11112b9d14019c9c2e4bee12787448a77d3345965b3bed8&url=https://online-pay.kakaopay.com/pay/r1/7beb59e892431fe2d11112b9d14019c9c2e4bee12787448a77d3345965b3bed8#Intent;scheme=kakaotalk;package=com.kakao.talk;end
카카오페이 결제창에는 뒤로가기 버튼도 없어서 결제 화면이 뜬 상태에서 결제를 진행하지도, 결제를 취소하지도 못하는 상황이 발생하게 된 것이다. 결국 결제 앱이 열리지 않는 경우에는 앱을 강제로 종료해야 하는 불편한 상황이 반복된다는 점이었다.
토스페이먼츠의 웹뷰(Webview) 연동하기 공식문서를 보니 앱스킴을 등록해야 하는 것을 알게 되었다.
토스페이먼츠 공식 문서의 예시는 “네이티브 프로젝트 파일(AndroidManifest.xml, Info.plist)”을 직접 수정하는 방식이었다. 하지만 Expo는 설정 파일(app.json, app.config.js)을 기반으로 자동 빌드를 진행하는 구조라 네이티브 파일을 직접 수정할 수 없었다.
다행히 Expo에서는 expo/config-plugins를 통해 빌드 시 필요한 네이티브 설정을 자동으로 주입하는 커스텀 플러그인을 등록할 수 있는 기능을 제공한다.
그래서 이 기능을 활용하여 Android용과 iOS용 각각의 플러그인을 직접 작성했고 해당 플러그인을 적용한 후에는 문제없이 웹뷰에서 카카오페이 결제가 정상적으로 동작했다.
// with-android-queries.cjs
const { withAndroidManifest } = require("@expo/config-plugins");
// 주입할 패키지 목록
const QUERY_PACKAGES = [
{ name: "com.kakao.talk" }, // 카카오톡
{ name: "com.nhn.android.search" }, // 네이버페이
{ name: "com.samsung.android.spay" }, // 삼성페이
{ name: "net.ib.android.smcard" }, // 모니모페이
{ name: "com.mobiletoong.travelwallet" }, // 신한카드 트레블월렛
{ name: "com.samsung.android.spaylite" }, // 삼성페이
{ name: "com.ssg.serviceapp.android.egiftcertificate" }, // SSGPAY
{ name: "com.nhnent.payapp" }, // PAYCO
{ name: "com.lottemembers.android" }, // L.POINT
{ name: "viva.republica.toss" }, // 토스
{ name: "com.shinhan.smartcaremgr" }, // 신한 슈퍼SOL
{ name: "com.shinhan.sbanking" }, // 신한 SOL뱅크
{ name: "com.shcard.smartpay" }, // 신한페이판
{ name: "com.shinhancard.smartshinhan" }, // 신한페이판-공동인증서
{ name: "com.hyundaicard.appcard" }, // 현대카드
{ name: "com.lumensoft.touchenappfree" }, // 현대카드-공동인증서
{ name: "kr.co.samsungcard.mpocket" }, // 삼성카드
{ name: "nh.smart.nhallonepay" }, // 올원페이
{ name: "com.kbcard.cxh.appcard" }, // KB Pay
{ name: "com.kbstar.liivbank" }, // Liiv(KB국민은행)
{ name: "com.kbstar.reboot" }, // Liiv Reboot(KB국민은행)
{ name: "com.kbstar.kbbank" }, // 스타뱅킹(KB국민은행)
{ name: "kvp.jjy.MispAndroid320" }, // ISP/페이북
{ name: "com.lcacApp" }, // 롯데카드
{ name: "com.hanaskcard.paycla" }, // 하나카드
{ name: "com.hanaskcard.rocomo.potal" }, // 하나카드
{ name: "kr.co.hanamembers.hmscustomer" }, // 하나멤버스
{ name: "kr.co.citibank.citimobile" }, // 씨티모바일
{ name: "com.wooricard.wpay" }, // 우리페이
{ name: "com.wooricard.smartapp" }, // 우리카드
{ name: "com.wooribank.smart.npib" }, // 우리WON뱅킹
{ name: "com.lguplus.paynow" }, // 페이나우
{ name: "com.kftc.bankpay.android" }, // 뱅크페이
{ name: "com.TouchEn.mVaccine.webs" }, // TouchEn mVaccine (신한)
{ name: "kr.co.shiftworks.vguardweb" }, // V-Guard (삼성)
{ name: "com.ahnlab.v3mobileplus" }, // V3 (NH, 현대)
{ name: "com.kakaobank.channel" }, // 카카오뱅크
];
/**
* AndroidManifest.xml에 queries 섹션을 추가하는 함수
*/
function addQueriesToManifest(androidManifest) {
const { manifest } = androidManifest;
if (!manifest) {
return androidManifest;
}
// queries 섹션이 이미 있는지 확인
if (!manifest.queries) {
manifest.queries = [];
}
// 기존 package 요소들을 확인하여 중복 제거
const existingPackages = new Set();
if (Array.isArray(manifest.queries)) {
manifest.queries.forEach((query) => {
if (
typeof query === "object" &&
query !== null &&
"package" in query &&
Array.isArray(query.package)
) {
query.package.forEach((pkg) => {
if (
typeof pkg === "object" &&
pkg !== null &&
"$" in pkg &&
typeof pkg.$ === "object" &&
pkg.$ !== null &&
"android:name" in pkg.$
) {
const pkgAttributes = pkg.$;
const packageName = pkgAttributes["android:name"];
if (typeof packageName === "string") {
existingPackages.add(packageName);
}
}
});
}
});
}
// 새로운 패키지들을 추가
const packagesToAdd = QUERY_PACKAGES.filter((pkg) => !existingPackages.has(pkg.name));
if (packagesToAdd.length > 0) {
// package 요소들을 생성
const packageElements = packagesToAdd.map((pkg) => ({
$: {
"android:name": pkg.name,
},
}));
// queries 배열에 package 요소들을 가진 객체 추가
if (!Array.isArray(manifest.queries)) {
manifest.queries = [];
}
// 기존에 package가 있는 query를 찾거나 새로 생성
let packageQuery = manifest.queries.find(
(query) =>
typeof query === "object" &&
query !== null &&
"package" in query &&
Array.isArray(query.package)
);
if (!packageQuery) {
packageQuery = { package: [] };
manifest.queries.push(packageQuery);
}
// 패키지들을 추가
packageQuery.package.push(...packageElements);
}
return androidManifest;
}
/**
* Android queries 플러그인
*/
const withAndroidQueries = (config) => {
return withAndroidManifest(config, async (config) => {
config.modResults = addQueriesToManifest(config.modResults);
return config;
});
};
module.exports = withAndroidQueries;
// with-ios-queries.cjs
const { withInfoPlist } = require("@expo/config-plugins");
// iOS에 주입할 앱스킴 목록 (토스페이먼츠 문서 기준)
const QUERY_SCHEMES = [
"supertoss", // 토스페이
"kb-acp", // 국민카드
"liivbank", // 국민카드
"newliiv", // 국민카드
"kbbank", // 국민카드
"nhappcardansimclick", // 농협카드
"nhallonepayansimclick", // 농협카드
"nonghyupcardansimclick", // 농협카드
"lottesmartpay", // 롯데카드
"lotteappcard", // 롯데카드
"mpocket.online.ansimclick", // 삼성카드
"ansimclickscard", // 삼성카드
"tswansimclick", // 삼성카드
"ansimclickipcollect", // 삼성카드
"vguardstart", // 삼성카드
"samsungpay", // 삼성카드
"scardcertiapp", // 삼성카드
"shinhan-sr-ansimclick", // 신한카드
"smshinhanansimclick", // 신한카드
"com.wooricard.wcard", // 우리카드
"newsmartpib", // 우리카드
"citispay", // 씨티카드
"citicardappkr", // 씨티카드
"citimobileapp", // 씨티카드
"cloudpay", // 하나카드
"hanawalletmembers", // 하나카드
"hdcardappcardansimclick", // 현대카드
"smhyundaiansimclick", // 현대카드
"shinsegaeeasypayment", // 간편결제
"payco", // 간편결제
"lpayapp", // 간편결제
"ispmobile", // ISP(BC/국민)
"tauthlink", // 본인인증
"ktauthexternalcall", // 본인인증
"upluscorporation", // 본인인증
"kftc-bankpay", // 뱅크페이
"kakaotalk", // 카카오톡
"wooripay", // 우리페이
"lmslpay", // 간편결제
"naversearchthirdlogin", // 네이버
"hanaskcardmobileportal", // 하나카드
"kb-bankpay", // KB 뱅크페이
"kakaobank", // 카카오뱅크
"monimopay", // 삼성카드
"monimopayauth", // 삼성카드
];
/**
* Info.plist에 LSApplicationQueriesSchemes를 추가하는 함수
*/
function addQueriesToInfoPlist(infoPlist) {
// 기존 LSApplicationQueriesSchemes가 있는지 확인
const existingSchemes = infoPlist.LSApplicationQueriesSchemes || [];
// 기존 스킴을 Set으로 변환하여 중복 제거
const existingSchemesSet = new Set(existingSchemes);
// 새로운 스킴 추가 (중복 제거)
const newSchemes = QUERY_SCHEMES.filter((scheme) => !existingSchemesSet.has(scheme));
if (newSchemes.length > 0) {
infoPlist.LSApplicationQueriesSchemes = [...existingSchemes, ...newSchemes];
} else if (!infoPlist.LSApplicationQueriesSchemes) {
// 기존 스킴이 없으면 새로 생성
infoPlist.LSApplicationQueriesSchemes = QUERY_SCHEMES;
}
return infoPlist;
}
/**
* iOS queries 플러그인
*/
const withIosQueries = (config) => {
return withInfoPlist(config, (config) => {
config.modResults = addQueriesToInfoPlist(config.modResults);
return config;
});
};
module.exports = withIosQueries;
// index.tsx - 실제 웹뷰가 사용되는 컴포넌트. 핵심 로직만 가져왔다.
import { Linking, Platform } from "react-native";
import WebView, { type WebViewNavigation } from "react-native-webview";
/**
* URL이 앱스킴인지 확인하는 함수
*/
const noAppScheme = ["http", "https", "about", "data", "javascript", "file"];
const isAppScheme = (url: string): boolean => {
if (!url) return false;
const scheme = url.split("://")[0];
return !noAppScheme.includes(scheme);
};
/**
* iOS에서 앱스킴 URL 처리
* Android는 네이티브 레벨에서 WebViewClient로 처리됨
*/
const onShouldStartLoadWithRequest = (request: WebViewNavigation): boolean => {
// iOS만 React Native 레벨에서 처리
if (Platform.OS === "ios") {
const { url } = request;
// 앱스킴 URL 처리 (http/https가 아닌 경우)
if (isAppScheme(url)) {
Linking.openURL(url).catch((error) => {
console.error("앱 실행 실패:", error);
});
return false; // WebView에서 로드하지 않음
}
}
// 일반 웹 URL은 WebView에서 로드
return true;
};
<WebView
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
/>
처음에는 “누군가 만든 라이브러리가 있나?” 혹은 “나와 같은 상황에 있는 사람이 있지 않을까?” 하는 마음에 검색했지만 관련된 글은 눈 씻고 찾아볼 수 없었다.

그래서 “나와 같은 상황을 겪는 사람들이 더 편하게 사용할 수 있게 해보자”는 마음으로 직접 작성했던 플러그인 코드를 기반으로 라이브러리화 작업까지 진행하게 되었다.
🤔 expo-tosspayments-webview란?
Expo + WebView 환경에서 토스페이먼츠 연동을 한 줄로 해결할 수 있는 라이브러리이다.
토스페이먼츠 공식 문서를 보면 WebView 연동을 위해 Android Manifest의 queries 섹션과 iOS Info.plist의 LSApplicationQueriesSchemes에 수많은 패키지/스킴을 추가해야 한다.
expo-tosspayments-webview는 이런 작업들을 Config Plugin을 통해 자동화해준다.
// app.json
{
"expo": {
"plugins": ["expo-tosspayments-webview"]
}
}
// WebView 컴포넌트
import { Linking } from "react-native";
import WebView, { type WebViewNavigation } from "react-native-webview";
import { shouldLoadURL } from "expo-tosspayments-webview/utils";
export default function PaymentWebView() {
const onShouldStartLoadWithRequest = (request: WebViewNavigation) => {
return shouldLoadURL(request.url, Linking);
};
return (
<WebView
source={{ uri: "https://webview-url.com" }}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
style={{ flex: 1 }}
/>
);
}
이게 끝이다! 네이티브 설정을 일일이 추가할 필요 없이 간단한 설정만으로 토스, 카카오페이, 카드사 앱 호출까지 모두 처리할 수 있다.
레포지토리에서 설치 방법과 더 자세한 사용 예제를 확인할 수 있다.
👉 expo-tosspayments-webview GitHub
버그 리포트나 기능 제안은 CONTRIBUTING.md를 참고해서 이슈를 남겨주시면 감사하겠습니다! ☺️
AI의 도움을 받긴 했지만 처음으로 직접 쓸모 있는 라이브러리를 만들어 배포해 본 경험이었다.
“작은 기능 하나라도 누군가에게 도움이 될 수 있지 않을까?” 하는 점이 꽤 뿌듯했고 새로운 개발 경험을 해봤다는 점에서도 의미 있었다.
앞으로 이 라이브러리를 더 많은 사람들이 사용해 보고, 피드백도 주고, 함께 유지 보수해 나가는 과정이 생긴다면 그것 또한 재미있는 경험이 될 것 같다. ㅎㅎ