토스페이먼츠 SDK를 사용해 본 경험이 있으신가요?
npm으로 토스페이먼츠 SDK를 받고 제공하는 함수에 '카드' 라는 props와 함께 실행 시키면 정말 간단하게 토스페이먼츠 카드 결제창 팝업이 뜨게 됩니다.
이런 간단한 개발자경험, 사용자경험을 토대로 빠른 속도로 시장 점유율을 확장시켜나가는 토스페이먼츠 SDK를 보면서 저 SDK는 어떻게 만드는걸까? 라는 생각이 들어 간단하게 로그인 SDK를 만들어 보고 싶었습니다.
Packages
- brandpay-sdk
- payment-sdk
- payment-widget-sdk
- sdk-loader
모노레포 형식으로 구성되어 있고 브랜드페이, 페이먼츠, 위젯, 로더 등의 프로젝트로 구성되어 있습니다. 각각의 프로젝트에는 별도로 배포한 URL이 있고 그 URL을 load하는 함수와 clear시켜주는 함수를 가지고 있습니다.
sdk-loader 패키지에서 스크립트를 로드하는 핵심 로직이 들어가있는데 하나하나 살펴보겠습니다.
// cachedPromise 변수는 로드된 스크립트에 대한 프로미스를 캐시하기 위해 사용됩니다.
let cachedPromise: Promise<any> | undefined;
// loadScript 함수는 두 개의 매개변수 src와 namespace를 받고, Namespace 유형의 프로미스를 반환합니다.
export function loadScript<Namespace>(src: string, namespace: string): Promise<Namespace> {
// existingElement 변수는 주어진 src와 일치하는 요소를 document에서 검색합니다.
// 이는 이미 스크립트가 로드되어 있는지 확인하기 위해 사용하는 flag입니다.
const existingElement = document.querySelector(`[src="${src}"]`);
// 이미 로드된 스크립트에 대한 캐시된 프로미스를 반환합니다.
// 이는 스크립트가 이미 로드되어 있는 경우 중복 로드를 방지하기 위한 최적화입니다.
if (existingElement != null && cachedPromise !== undefined) {
return cachedPromise;
}
// 이는 이미 해당 네임스페이스가 존재하는 경우 중복 로드를 방지하고, 이미 존재하는 네임스페이스를 사용할 수 있도록 합니다.
if (existingElement != null && getNamespace(namespace) !== undefined) {
return Promise.resolve(getNamespace<Namespace>(namespace)!);
}
// script 변수는 새로운 script 요소를 생성하고, src 속성을 주어진 src 값으로 설정합니다.
const script = document.createElement('script');
script.src = src;
// cachedPromise에 새로운 프로미스를 할당합니다. 이 프로미스는 스크립트 로드를 처리하고,
// TossPayments:initialize:${namespace} 이벤트가 발생했을 때 해결하거나 거부합니다
cachedPromise = new Promise<Namespace>((resolve, reject) => {
document.head.appendChild(script);
window.addEventListener(`TossPayments:initialize:${namespace}`, () => {
if (getNamespace(namespace) !== undefined) {
resolve(getNamespace(namespace) as Namespace);
} else {
reject(new Error(`[TossPayments SDK] Failed to load script: [${src}]`));
}
});
});
return cachedPromise;
}
function getNamespace<Namespace>(name: string) {
return (window[name as any] as any) as Namespace | undefined;
}
캐싱 처리가 된 훌륭한 loader를 얻었습니다.
mkdir sdk-login
cd sdk-login
npm init -y
//LoginScreen.js
import React, { useState } from 'react';
const LoginScreen = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleLogin = () => {
const user = {
username,
};
onLogin(user);
};
return (
<div>
<h2>Login Screen</h2>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleLogin}>Login</button>
</div>
);
};
export default LoginScreen;
import React from 'react';
import { createRoot } from 'react-dom/client';
import LoginScreen from './LoginScreen';
const SDK = {
openLoginScreen: (onLogin) => {
createRoot(document.getElementById('sdk-root')).render(
<React.StrictMode>
<LoginScreen onLogin={onLogin} />
</React.StrictMode>
);
},
};
export default SDK;
npm install -g parcel-bundler
&&
"scripts": {
"build": "parcel build src/index.js --out-dir dist --out-file sdk.js",
}
npm publish
example.
import SDK from '9bin-first-sdk';
const onLogin = (user) => {
console.log('Logged in:', user);
// Perform any necessary actions after successful login
SDK.openLoginScreen(onLogin);
};
- login page는 S3에 업로드 후 url을 만든다.
- SDK hub 생성 하여 npm업로드 한다.
- SDK hub는 login page js파일을 불러와 head에 script 형식으로 삽입 시켜주거나, script를 제거 하는 역할을 한다.
- 프로젝트에서 SDK hub를 가져와 loadScript, clearScript 호출로 login page를 생성, 삭제한다.
- 허브를 두어 login page 업데이트에 대한 의존성을 끊어주고 SDK hub에 load, clear 역할을 위임한다.
https://9binlogin.s3.ap-northeast-2.amazonaws.com/sdk.js
//loadSDK.js
import { loadScript } from './sdk-loader';
import { SCRIPT_URL } from './constants';
export function loadSDK(service) {
if (typeof window === 'undefined') {
// SSR할 때 생성자가 사용되는 경우 에러를 발생시키지 않는대신 정상적인 인스턴스를 반환하지 않는다.
return Promise.resolve({});
}
console.log('loadSDK init')
// regenerator-runtime 의존성을 없애기 위해 `async` 키워드를 사용하지 않는다
return loadScript(SCRIPT_URL, service).then((sdk) => {
return sdk();
});
}
//clearSDK.js
import { SCRIPT_URL } from './constants';
export function clearSDK() {
const script = document.querySelector(`script[src="${SCRIPT_URL}"]`);
const sdkContainer = document.getElementById('sdk-container')
script?.parentElement?.removeChild(script);
sdkContainer?.parentElement?.removeChild(sdkContainer)
}
export const SCRIPT_URL = 'in your url';
//load-sdk.ts
let cachedPromise: Promise<any> | undefined;
export function loadScript<Namespace>(src: string, namespace: string): Promise<Namespace> {
const existingElement = document.querySelector(`[src="${src}"]`);
if (existingElement != null && cachedPromise !== undefined) {
return cachedPromise;
}
if (existingElement != null && getNamespace(namespace) !== undefined) {
return Promise.resolve(getNamespace<Namespace>(namespace)!);
}
const script = document.createElement('script');
script.src = src;
cachedPromise = new Promise<Namespace>((resolve, reject) => {
document.head.appendChild(script);
window.addEventListener(`SDK:initialize:${namespace}`, () => {
if (getNamespace(namespace) !== undefined) {
resolve(getNamespace(namespace) as Namespace);
} else {
reject(new Error(`[SDK] Failed to load script: [${src}]`));
}
});
});
return cachedPromise;
}
function getNamespace<Namespace>(name: string) {
return (window[name as any] as any) as Namespace | undefined;
}
import { loadSDK, clearSDK } from '9bin-hub-sdk';
const onLogin = (user) => {
loadSDK('login');
};
const removeSDK = () => {
clearSDK();
};
onLogin 함수를 실행하면 head에 별도로 배포해놓은 src를 포함한 script 태그가 삽입되고
sdk-container 태그를 생성하면서 그 태그 내부로 login page가 생성되고
removeSDK 함수를 실행하면 head의 스크립트 태그와 sdk-container를 제거하게 된다.