로그인 SDK 만들어보기

김규빈·2023년 7월 4일
0
post-custom-banner

토스페이먼츠 SDK를 사용해 본 경험이 있으신가요?
npm으로 토스페이먼츠 SDK를 받고 제공하는 함수에 '카드' 라는 props와 함께 실행 시키면 정말 간단하게 토스페이먼츠 카드 결제창 팝업이 뜨게 됩니다.

이런 간단한 개발자경험, 사용자경험을 토대로 빠른 속도로 시장 점유율을 확장시켜나가는 토스페이먼츠 SDK를 보면서 저 SDK는 어떻게 만드는걸까? 라는 생각이 들어 간단하게 로그인 SDK를 만들어 보고 싶었습니다.

토스페이먼츠SDK repo 살펴보기

💡
토스페이먼츠 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를 얻었습니다.

Login SDK 만들어보기

1.Login page 만들기

mkdir sdk-login
cd sdk-login
npm init -y

2.React로 로그인 페이지 작성

//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;

3.index.js작성

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;

4. js파일로 빌드

npm install -g parcel-bundler

&&

"scripts": {
	"build": "parcel build src/index.js --out-dir dist --out-file sdk.js",
}

5. npm 배포

npm publish

6. SDK 사용할 프로젝트에서 불러오기

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);
  };

7. 예시

8.결과

  • 로그인 페이지가 필요한 프로젝트는 npm i 9bin-first-sdk 통해 다른 프로젝트에서 관리되고 있는 로그인 페이지를 통해 로그인 시도 한다.
  • 로그인 시도시 사용하는 onLogin 함수를 통해 호출 주체는 fallback으로 결과만 알게 된다.
  • login 관련 동작을 SDK에게 모두 위임하고 프로젝트 내부에선 결과에 따라 사이드 이펙트 핸들링만 담당한다.

9.한계

  • 이러한 SDK ↔ 프로젝트 방식은 SDK 내부 비즈니스 로직에 크게 영향을 받는다.
  • SDK에 새로운 배포가 생길 경우 SDK를 사용하는 모든 프로젝트는 패키지 버전 대응을 해야된다.
    • Login page(호스팅) ↔ SDK(npm) ↔ 프로젝트 구조에선 login page와 프로젝트의 연관 관계를 끊어줌으로써 SDK를 사용하는 프로젝트는 오직 호스팅 페이지에 영향을 받는다.

개선된 Login SDK 만들기

강결합을 분리하기 위한 시나리오는 아래와 같다.

  • 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 역할을 위임한다.

1. S3 버킷에 login page 업로드

https://9binlogin.s3.ap-northeast-2.amazonaws.com/sdk.js

2. SDK hub 생성

//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;
}

3. 프로젝트에서 loadSDK, clearSDK 사용

import { loadSDK, clearSDK } from '9bin-hub-sdk';

const onLogin = (user) => {
    loadSDK('login');
  };

const removeSDK = () => {
    clearSDK();
  };

4.결과

onLogin 함수를 실행하면 head에 별도로 배포해놓은 src를 포함한 script 태그가 삽입되고
sdk-container 태그를 생성하면서 그 태그 내부로 login page가 생성되고
removeSDK 함수를 실행하면 head의 스크립트 태그와 sdk-container를 제거하게 된다.

결론

  • 일관된 로그인 페이지를 npm install 9bin-first-sdk 를 통해 불러올 수 있다.
  • 이 프로젝트를 확장한다면, 분리해야되는 피쳐 (게임, 콜택시, etc…) 를 sdk 프로젝트 내부에 모노레포 형식으로 녹인다면 별도 관리도 가능해 보인다.
profile
FrontEnd Developer
post-custom-banner

0개의 댓글