코드 조각을 모으고 있는 개발자

브라우저용 번들링 플러그인, 직접 만들었어요

profile-img
신지호토스페이먼츠 Frontend Developer
2024. 1. 10

지난해 말, 토스페이먼츠 DX(Developer eXperience) 팀은 개발자 경험을 위해 샌드박스라는 제품을 출시했어요. 샌드박스는 브라우저에서 바로 연동 흐름을 체험하고, 테스트 연동을 해볼 수 있는 개발자 도구예요. GitHub 레포지토리를 클론하거나 VS Code를 띄울 필요 없이 브라우저에서 바로 코딩하고 실행할 수 있는 코드 에디터라고 할 수 있어요. 샌드박스를 활용하면 코드와 결과물이 어떻게 서로 연결되어 있는지 한눈에 볼 수 있어 편리해요.

이 코드 에디터에서 가장 중요한 기능은 사용자가 코드를 수정할 때마다 미리보기에 업데이트해 주는 것이었어요. 이 기능을 위해 프론트엔드 번들링 플러그인을 만든 방법을 소개할게요.

샌드박스 환경과 번들링

번들링은 여러 개의 파일을 하나로 묶어 최종적으로 실행되는 웹 애플리케이션을 만드는 과정을 뜻해요. 우리 프로젝트에는 React, 결제위젯 SDK, react-router-dom 등의 외부 라이브러리를 사용하고 있고, 이 라이브러리들을 번들링해서 보여줘야 하죠.

샌드박스에서는 실시간으로 변경되는 코드를 번들링 한 뒤 컴파일해야 하는데요. 번들링 해야 하는 환경이 브라우저라는 점이 일반적인 웹 애플리케이션과 다른 점이에요. 일반적인 번들링 과정은 파일 시스템에 직접 접근하여 파일들을 처리하고 합치는데, 브라우저는 보안상의 이유로 파일 시스템에 직접 접근할 수 없어요. 그래서 다른 방법을 찾아야 했습니다.

브라우저에서 번들링하기

이 문제를 풀 수 있는 방법 중 하나는 가상 환경을 활용하는 거예요. Sandpack이나 WebContainer 같은 도구들은 가상환경에 사용자가 입력한 코드를 전달하고 실제로 서버를 띄우는 방식이에요. 서버로 띄운 결과를 브라우저의 iframe에 임베드해서 보여주는 방식이죠. 이렇게 하면 실제 파일 시스템에 접근할 필요 없이, 브라우저 상에서 코드를 실행하고 결과를 볼 수 있어요.

하지만 이 방식을 모든 상황에 적용하기는 어려워요. 예를 들어, iframe 특성상 주소 이동을 할 수 없는데 토스페이먼츠 SDK는 연동 과정에서 리다이렉트가 꼭 필요합니다. 그래서 위와 같이 가상 환경을 활용하는 iframe 기반 샌드박스 라이브러리는 사용할 수 없었어요.

그래서 코드를 어떻게 브라우저 위에서 번들링 할 수 있을지 계속 찾아보다가 Rollup에서 제공하는 브라우저용 라이브러리를 알게 됐습니다. Rollup은 모듈 번들러인데요, @rollup/browser는 Node.js 환경이 아닌 브라우저에서 Rollup이 동작하도록 Node.js와 관련된 의존성을 제거하고 browser API로만 만든 라이브러리에요. 브라우저 환경에서의 번들링에 사용하죠. 그래서 토스페이먼츠처럼 샌드박스를 구현한 곳들(예: Rollup, Svelte , Preact) 대부분이 이 라이브러리를 사용하고 있어요.

@rollup/browser 라이브러리 이해하기

그래서 저도 이 라이브러리를 활용해 보기로 했어요. @rollup/browser는 파일 시스템이 아닌 메모리상의 데이터를 다뤄요. 실제 파일 시스템에 접근하는 대신 JavaScript 객체에 저장된 코드를 처리한다는 뜻이에요. 아래 예시를 보면서 이해해 볼게요.

const modules = {
  'index.js': `
    import { createRoot } from 'react-dom/client';
    import { Checkout } from './Checkout.jsx';
  `,
  'Checkout.jsx': `
    import { useEffect, useRef, useState } from "react";
    import { loadPaymentWidget, ANONYMOUS } from "@tosspayments/payment-widget-sdk";
    // ...
    function Checkout() {
      // ...
      return <div></div>
    }
  `,
  // ...
};

rollup({})
  1. 파일 시스템을 대신하는 데이터

    위 코드 예제에서 modules라는 객체는 여러 JavaScript 파일을 표현합니다. 각 키는 파일의 경로처럼 사용되고, 각 값은 해당 파일의 소스 코드를 나타냅니다. 이 객체는 파일 시스템에 있는 실제 파일이 아니라, 메모리상에 존재하는 데이터에요. 전통적인 번들링 도구는 파일 시스템에서 파일을 읽고 쓰지만, @rollup/browser는 이 modules 객체 내의 정보를 사용합니다.

    코드에서 보이는 index.jsCheckout.jsx는 실제 파일이 아니라, modules 객체 내에 정의된 가상의 파일입니다.

  2. 가상의 '파일' 처리

    rollup은 이 가상의 파일들을 읽고 처리하여, 마치 실제 파일 시스템에서 작업하는 것처럼 번들링합니다.

    번들링이 잘 되면 개발자가 브라우저에서 코드를 수정할 때마다 변경 사항이 메모리상의 객체에 즉시 반영됩니다. 그리고 @rollup/browser는 이 변경된 코드를 사용하여 새로운 번들을 생성합니다.

  3. Rollup 설정

    rollup({}) 부분은 Rollup을 구성하는 설정을 정의합니다. 이 설정에는 번들링 과정에서 어떤 모듈을 어떻게 처리할지, 결과물을 어떻게 출력할지 등의 정보가 포함됩니다. 여기에 번들링 플러그인 로직을 작성하면 됩니다.

이제 하나의 파일로 만들기 위한 번들링 로직을 직접 구현해 볼게요.

브라우저용 번들링 플러그인 만들기

React 같은 외부 라이브러리를 불러온다고 생각해 볼게요. 먼저 일반적인 Node.js 환경이라고 생각하면, 해당 라이브러리가 어디 있는지 Rollup이 알 수 없어요. 그래서 Rollup은 @rollup/plugin-node-resolve라는 플러그인을 사용해서 외부 라이브러리의 코드를 node_modules에서 찾아냅니다.

그런데 브라우저 환경에는 파일 시스템이 없어서 네트워크를 통해 외부 라이브러리를 불러옵니다. Rollup은 ESM을 기반으로 하고 있으니 esm.sh를 활용해서 CDN 주소를 생성했어요. 이제 지정한 위치의 코드를 불러오기 위한 로직이 필요하겠죠. 그래서 외부 라이브러리를 불러오는 Rollup 플러그인을 직접 만들었는데요. Rollup 플러그인의 라이프사이클과 함께 자세히 살펴볼게요.

라이프 사이클이 좀 복잡해 보이지만, 이 중에서 resolveId, load, transform 메서드만 사용해도 충분했어요. 각 메서드와 작성한 로직은 아래와 같아요.

resolveId: 모듈 위치 식별 및 맵핑

모듈 식별자가 어디에 존재하는지 해석하는 단계에요. 예를 들어 다음과 같은 import 문에서 .Checkout.jsx을 실제 파일 시스템 경로로 맵핑합니다.

import { Checkout } from './Checkout.jsx'

우리는 파일 시스템 구조를 사용하지 않기 때문에 이 단계에서 해당 파일이 메모리상에 존재하는지 확인하는 로직을 추가해야 합니다.

const modules = {
  'index.js': `
    import { createRoot } from 'react-dom/client';
    import { Checkout } from './Checkout.jsx';
  `,
  'Checkout.jsx': `
    import { useEffect, useRef, useState } from "react";
    import { loadPaymentWidget, ANONYMOUS } from "@tosspayments/payment-widget-sdk";
    // ...
    function Checkout() {
      // ...
      return <div></div>
    }
  `,
  // ...
};

plugins: [
  {
    name: 'sandbox-loader',
    resolveId(source) {
      if (modules.hasOwnProperty(source)) {
        return source;
      };

      // ./Checkout.jsx -> Checkout.jsx
      if (modules.hasOwnProperty(getOnlyFileName(source))) { 
        return getOnlyFileName(source);
      }
	},

위 예제 코드는 React를 불러오는 부분인데요. 만약 resolvedId에 들어온source가 ‘react’라면 다음과 같이 https://esm.sh/react를 반환하는 방식이에요. 앞서 설명했던 esm.sh를 활용했어요.

const url = new URL(source, 'https://esm.sh');

return url.href;

load: 코드 가져오기

resolveId에서 식별한 위치에 있는 코드를 반환하는 단계에요. modules 객체로 관리되는 파일이면 객체에 있는 코드 내용을 사용하고, 외부 라이브러리면 resolveId에서 정의한 CDN 링크로 코드 내용을 가져옵니다.

load(id) {
  if (modules.hasOwnProperty(id)) {
    return modules[id];
  }
	
  return getExternalModule(id);
}

// getExternalModule.ts
const cache = new Map(); // 이미 불러온 라이브러리는 cache에 저장해 둡니다.
export async function getExternalModule(url: string): Promise<string> {
  if (cache.has(url)) {

transform: 코드 변형하기

load에서 불러온 코드를 변형하는 단계에요. 기본 JavaScript를 사용한다면 필요하지 않은 단계지만, React와 같은 라이브러리를 사용해 코드에 JSX 문법이 있다면 JavaScript로 변환해 줘야 합니다. 코드 변환을 위한 도구로는 sucrase를 사용했어요.

babel은 다양한 브라우저 커버리지가 높지만, sucrase는 브라우저 커버리지보다는 모던 JavaScript 환경을 타겟팅하여 속도에 초점을 맞춘 트랜스파일러인데요. 샌드박스 제품은 코드 수정이 생기는 빈도가 잦고, 수정이 생길 때마다 빌드를 새로 해서 속도가 빠른 게 중요하다고 판단해서 sucrase를 사용했습니다. @babel/standalone(브라우저에서 동작하는 babel 모듈)과 React 코드의 트랜스파일 속도를 비교해 보면 5~10배 정도 차이가 났답니다.

sucrase를 사용해서 JSX를 기본 JavaScript 문법으로 변환하기 위한 코드는 다음과 같아요.

import { transform as sucraseTransform } from 'sucrase';

transform(code, id) {
  const output = sucraseTransform(code, {
    filePath: `/${id}`,
    transforms: ['jsx'],
    jsxRuntime: 'automatic', // React 17 이후 변경된 JSX Transform 방식
    production: true,
  });

  return output.code;
},

위 설정까지 마치면 이제 React 코드를 브라우저에서 실시간으로 빌드할 수 있어요. 마지막으로 사용자가 빌드 결과물을 볼 수 있도록 미리보기 영역의 HTML 요소에 script를 삽입해 주면 완성입니다. element.innerHTML에서는 script 태그가 동작하지 않아서 다음과 같은 방법으로 script 태그를 넣어줬어요.

function injectHtmlWithBundledCode(previewElement: HTMLDivElement, html: string, js: string) {
  // 1. script 태그를 제외한 html 삽입
  previewElement.innerHTML = html;
  const scriptElement = document.createElement('script');
  const scriptText = document.createTextNode(js);
  scriptElement.appendChild(scriptText);
  // 2. script 엘리먼트 삽입
  previewElement.appendChild(scriptElement);
}

// Preview.tsx

const { html, js } = useSandboxCode();

useEffect(() => {
  if (previewRef.current != null) {
    injectHtmlWithBundledCode(previewRef.current, html, js);
  }
}, [html, js]);

...

이제 브라우저 위에서 파일 시스템과 같은 방식으로 코드를 번들링하고 실행할 수 있게 됐어요.

남은 과제들

저에게는 앞으로 남은 과제들이 몇 가지 더 있는데요. 이번 MVP에서는 JavaScript와 React만 사용할 수 있어요. 그래서 React를 위한 처리 로직만 있는데요. 앞으로 Vue, Svelte와 같은 프론트엔드 도구들도 비슷한 방식으로 빌드할 수 있도록 로직을 추가하려고 해요. 또, 지금은 샌드박스에 파일 시스템이 없어서 모든 파일이 같은 레벨로 열려있는데, 가상 파일 시스템을 만든 뒤 플러그인 로직을 파일 시스템에 맞춰 작성해 볼 예정입니다. 앞으로 토스페이먼츠 개발자경험이 어떻게 더 진화하는지 지켜봐 주세요.

함께 읽으면 좋은 콘텐츠

📍결제 연동 코드없이 시작하기

Write 신지호 Review 한재엽 Edit 한주연

재미있게 읽으셨나요?

좋았는지, 아쉬웠는지, 아래 이모지를 눌러 의견을 들려주세요.

😍
🤔
website-code-blue

토스팀이 만드는 수많은 혁신의 순간들

당신과 함께 만들고 싶습니다.
지금, 토스팀에 합류하세요.
채용 중인 공고 보기