AI가 만든 코드가 어드민이 되기까지

이현재
2026년 9월 4일

어드민 하나를 만들려면 무엇을 해야 할까요?

프로젝트를 스캐폴딩하고, 필요한 의존성을 추가하고, 빌드 도구를 고르고, 인증을 붙이고, 배포를 설정해요. 그다음 프론트엔드 코드를 작성하고 API를 연결한 후 빌드해서 배포해요. 어드민이라면 여기에 개인정보 마스킹, 정보 조회 사유 수취, 다운로드 파일 암호화 같은 보안 요건을 추가로 고려해야 해요. 간단한 어드민 하나를 만들려고 해도 해야 할 일이 많죠.

토스에서는 여러 팀이 빠르게 비즈니스를 시도하면서 다양한 어드민을 만들고 수정할 일이 꾸준히 생겨요. 기존에는 어드민마다 개발/보안/배포 과정을 반복해야 해서, 수요가 커질수록 비용도 같이 커졌어요. 공통으로 필요한 정책과 실행 환경을 표준화하면서도, 각 팀에 필요한 어드민을 빠르게 만들 수 있는 방식이 필요했어요.

TOI는 이 문제를 풀기 위해 만든 사내 어드민 제작 플랫폼이에요. 사용자는 어드민에서 쓸 API를 등록하고, 자연어로 필요한 화면을 요청해요. AI는 사용자가 등록한 API의 요청과 응답 스키마를 참고해서 React 코드를 만들어요. 2026년 2월 사내에 공개한 이후 2026년 8월까지 6개월간 프로젝트 439개와 그 하위 페이지 2,418개가 만들어졌어요.

데이터 조회 어드민을 만들어줘

코드는 AI가 작성해 줬지만, 실시간으로 만들어지는 코드가 사용자에게 어드민으로 보이려면 실행하고 보여 줄 환경이 필요했어요. 이 글에서는 TOI가 그 환경을 브라우저 안에 만든 과정을 소개할게요.

어드민을 정책과 화면으로 나눠서 생각하기

어드민에서 지켜야 하는 정책들은 대부분 데이터를 다루는 방식과 관련되어 있어요. TOI로 만든 어드민에서 API를 호출하려면 먼저 해당 API를 TOI에 등록해야 해요. 등록된 API 요청은 TOI 서버를 거쳐 원래 서버로 전달되고, TOI 서버에는 호출 기록이 자동으로 남아요. API별로 개인 정보 마스킹, 다운로드 파일 암호화, 정보 조회 사유 수취 같은 정책을 설정할 수 있고, 필수인 보안 정책들은 자동으로 적용돼요. TOI 서버는 API를 프록시하는 과정에서 이 설정과 정책을 적용해요.

다음 영역은 데이터를 화면에 보여 주고 기능을 동작하게 하는 일이에요. 테이블을 그리고, 필터를 붙이고, 상세 페이지로 이동하는 기능이 여기에 해당해요. TOI는 이 영역을 AI에게 맡겨서, 사용자가 필요한 어드민 화면을 쉽고 빠르게 만들 수 있도록 했어요.

다만 AI가 프론트엔드 코드를 만들었다고 바로 화면이 나타나지는 않아요. 앞에서 나열한 것처럼 코드가 동작하는 서비스가 되려면 코드를 만드는 것 외에도 할 일이 많아요. TOI에서는 AI가 만든 코드를 사용자가 제품에서 실시간으로 확인할 수 있어야 했고, 이를 위해 개발 환경 자체를 브라우저 안으로 가져와야 했어요.

Preview Runtime을 위한 두 가지 시도

AI가 만든 코드를 실행해서 바로 보여 줄 화면이 필요했고, 이를 위해 두 가지 방식을 차례로 시도했어요.

Dev Server

처음에는 개발자에게 익숙한 Dev Server를 사용해서 시도했어요. 서버에 Next.js Dev Server를 띄우고 그 주소를 iframe으로 연결해서 사용자에게 보여 줬어요. 로컬에서 개발하던 방식을 그대로 서버로 옮겼고, 코드가 수정될 때마다 Dev Server가 바라보는 파일이 변경되고 iframe이 갱신됐어요.

코드 변경 내용을 어렵지 않게 실시간으로 보여 줄 수 있었고, PoC에는 적절했어요. 하지만 TOI에서는 여러 사용자가 Preview를 보면서 편집할 수 있어야 하는데, 하나의 Dev Server 프로세스에 연결된 Preview에는 구조적인 한계가 있었어요. Next.js Dev Server는 컴파일 에러가 나면 에러 오버레이를 보여 주는데, 사용자 A가 페이지를 편집하다가 컴파일 에러가 나면 오버레이가 A의 화면에만 나타나지 않고 다른 페이지를 편집하고 있는 사용자에게도 보였어요.

실행 환경을 사용자별로 나누지 않아서 생긴 문제였어요. Dev Server 프로세스를 사용자 수만큼 늘리는 대신, 코드 실행 위치를 각 사용자의 브라우저로 옮겨 서로 격리하기로 했어요.

Sandpack

두 번째 시도에서는 CodeSandbox의 Sandpack을 사용해 코드 실행 위치를 서버에서 사용자 브라우저로 옮겼어요. 기대한 대로 Preview는 사용자마다 독립적으로 실행됐고, 사용자 A가 코드를 편집하다 낸 오류가 다른 사용자의 화면에는 나타나지 않았어요.

대신 예상하지 못한 다른 문제가 생겼는데, 페이지에 진입 후 Preview가 보이기까지 47초가 걸렸어요. Sandpack은 페이지에 진입해서 렌더링을 시작할 때, 프로젝트에 선언한 패키지를 모두 내려받는 식으로 동작했기 때문에 느릴 수밖에 없었어요. 실시간으로 생성/편집하는 코드와 Preview가 중심인 제품에서 허용하기 어려운 사용자 경험이었어요.

사내 패키지를 연결하는 과정에서도 문제가 있었어요. 사내 패키지는 인증된 경우에만 사용할 수 있도록 private하게 관리되고 있었어요. Sandpack은 private npm registry를 지원했지만, 인증 정보를 브라우저 런타임에 노출할 수는 없었어요. 그래서 중간에서 인증을 대신 수행해 주는 Proxy API가 필요했어요.

그런데 패키지를 가져올 때는 패키지 정보를 담은 metadata와, 실제 코드를 압축한 tarball을 순서대로 내려받아요. Proxy API는 이 두 요청 모두에 대응해야 했어요. metadata 요청을 인증해 주는 것만으로는 부족했는데, metadata 안의 dist.tarball이 Proxy API가 아니라 원본 registry를 가리키고 있어서, tarball 요청도 proxy를 거치도록 URL을 다시 바꿔 줘야 했거든요.

패키지 설치는 의존성 그래프를 따라 내려가면서 하위 패키지의 metadata와 tarball을 계속 요청해요. 사내 패키지가 또 다른 사내 패키지에 의존하는 경우도 있어서, 그래프 어느 단계에서 사내 패키지를 만나든 같은 인증과 URL 치환을 적용해야 했어요.

마지막으로 Sandpack Preview는 별도 origin의 iframe에서 실행돼요. 이 iframe에서 API를 호출하면 CORS와 PNA(Private Network Access) 문제가 생겨서, 여기에도 대응이 필요했어요.

Sandpack은 실행 환경을 사용자 브라우저로 옮기는 데는 적합했어요. 하지만 첫 화면까지 걸리는 시간이 너무 길었고, 패키지를 원하는 시점과 방식으로 공급하기도 어려웠어요. 이 문제들을 하나씩 우회하며 Sandpack을 유지하기보다는, 다른 방식으로 문제를 해결하기로 했어요.

Preview Runtime

두 번의 시도를 거치며, 우리가 풀어야 하는 문제가 세 가지로 정리됐어요.

TOI는 이 세 가지를 Preview Runtime을 만들면서 해결했어요. 이제 하나씩 순서대로 소개할게요.

1️⃣ 브라우저에서 코드 빌드하기

먼저 Preview Runtime 시스템에서 어떻게 브라우저 위에서 코드를 빌드할 수 있도록 했는지 설명해 드릴게요.

브라우저에는 파일 시스템이 없어요

실시간으로 생성되는 앱 코드를 브라우저에서 빌드하려면, 빌드 도구가 파일 시스템에서 프로젝트 파일을 찾고 import 경로를 해석할 수 있어야 해요. 예를 들어 ./components/Table이라는 경로를 만나면 실제로 존재하는 ./components/Table.tsx./components/Table/index.tsx 파일로 해석해요. 하지만 브라우저에는 이런 경로를 조회할 프로젝트 파일 시스템이 없어서, 경로와 파일 내용을 연결하는 가상 파일 시스템을 만들었어요.

가상 파일 시스템은 경로에 파일이 있는지 확인하고 그 내용을 가져오는 역할을 해요. 파일은 소유 범위와 변경 주기에 따라 사용자 파일, 프로젝트 공유 파일, Preview 템플릿 파일, Preview Runtime 파일의 네 레이어로 나눴어요.

export interface RuntimeFileStore {
  // 경로에 해당하는 파일 내용을 반환해요.
  get: (path: string) => string | undefined;
  // import 경로를 해석할 때 파일의 존재 여부를 확인해요.
  has: (path: string) => boolean;
}

// 배열의 앞쪽에 있을수록 우선순위가 높아요.
const fileStore = createLayeredRuntimeFileStore([
  userLayer,     // AI가 만들거나 사용자가 편집한 현재 페이지의 코드
  projectLayer,  // 여러 페이지가 공유하는 프로젝트 범위의 코드
  templateLayer, // index.html, index.tsx 같은 Preview Shell 템플릿 코드
  runtimeLayer,  // React 앱을 실행하고 오류를 연결하는 Preview Runtime 코드
]);

fileStore는 파일을 요청받으면 앞쪽 레이어부터 has로 확인하고, 처음 찾은 파일의 내용을 get으로 가져와요. 빌드 도구는 이렇게 합친 네 레이어를 하나의 파일 시스템처럼 사용해요.

esbuild-wasm으로 번들 만들기

가상 파일 시스템을 준비했으니 이제 이걸 읽어서 앱 코드를 번들로 만들 차례예요. 빌드 도구로는 esbuild의 WebAssembly 버전인 esbuild-wasm을 사용했어요. 브라우저에서 그대로 실행되고, 플러그인으로 파일 조회 과정을 가로챌 수 있어서 앞에서 만든 가상 파일 시스템을 그대로 연결할 수 있어요. esbuild-wasm으로 TypeScript와 JSX를 브라우저가 실행할 JavaScript로 변환하고, 로컬 import 그래프를 따라가며 하나의 ESM 번들을 만들어요. 변환과 번들링은 worker 옵션으로 Web Worker에서 처리해 메인 스레드의 작업과 분리했어요.

await esbuild.initialize({
  wasmURL: esbuildWasmUrl,
  // 모듈을 Web Worker에서 실행해요.
  worker: true,
});

빌드 설정에는 앞에서 만든 가상 파일 시스템을 플러그인으로 넣어요.

const result = await esbuild.build({
  bundle: true,
  entryPoints: [normalizedEntryPath],
  format: 'esm',
  jsx: 'automatic',
  platform: 'browser',
  plugins: [
    createVirtualFsPlugin(fileStore, {
      // import map의 키만 external로 인정해요.
      // 등록되지 않은 패키지 import는 빌드 오류로 처리해요.
      externalSpecifiers: new Set(externalSpecifiers),
      // 프로젝트의 tsconfig.json paths 별칭을
      // 가상 파일 시스템 안의 실제 경로로 해석할 수 있도록 사용해요.
      tsconfigPaths: compilerConfig.tsconfigPaths,
    }),
  ],
  write: false,
});

createVirtualFsPlugin은 esbuild가 디스크에 묻는 요청을 대신 답해 줘요. onResolve에서 import 경로를 실제 파일 경로로 바꾸고, onLoad에서 그 경로의 내용을 돌려줘요.

build.onResolve({ filter: /.*/ }, (args) => {
  // 상대 경로, 절대 경로, tsconfig paths 별칭 등을
  // 가상 파일 시스템 안의 파일 경로로 바꿔요.
  const resolvedPath = resolveImportPath(
    fileStore,
    args.importer,
    args.path,
    options.tsconfigPaths,
  );

  if (resolvedPath != null) {
    return {
      path: resolvedPath,
    };
  }

  // 로컬 파일로 찾지 못했지만 import map에 있는 패키지라면
  // esbuild가 번들에 포함하지 않도록 external로 남겨요.
  if (options.externalSpecifiers.has(args.path)) {
    return {
      path: args.path,
      external: true,
    };
  }

  // 로컬 파일도 아니고 import map에도 없다면
  // 브라우저에서 실행할 수 없는 import이므로 빌드를 실패시켜요.
  return { ... };
});

build.onLoad(
  { filter: /.*/, namespace: 'preview-virtual-file' },
  (args) => {
    // onResolve에서 결정한 가상 경로의 실제 내용을 가져와요.
    const contents = fileStore.get(args.path);

    return { ... };
  },
);

한편, 프로젝트에서 사용할 react, date-fns 와 같은 패키지는 external 설정으로 빌드 대상에서 빠지도록 했어요. 대신 뒤에서 설명할 import map을 이용해 실행 시점에 미리 준비해둔 패키지를 연결해요.

2️⃣ 패키지를 미리 준비하기

여기까지 브라우저에서 앱 코드를 빌드하는 방법을 알아봤어요. 이제 번들에 포함하지 않고 남겨 둔 패키지를 미리 준비해서 제공한 방법을 설명할게요.

Import Map으로 패키지 제공하기

빌드한 앱 번들에는 import React from 'react' 같은 import 문이 그대로 남아 있어요. 그런데 'react'라는 이름만으로는 브라우저가 어떤 파일을 불러와야 하는지 알 수 없어요.

브라우저는 이 이름을 실제 파일에 연결하는 표준 방법으로 import map을 제공해요. 예를 들어 아래처럼 정의하면 'react'를 import할 때 react_27397108.js를 불러오게 돼요.

{
  "imports": {
    "react": "react_27397108.js"
  }
}

import map을 사용하면 패키지 연결을 번들 밖 JSON 데이터로 표현할 수 있어요. TOI는 패키지를 미리 빌드해 두고, import map으로 연결해서 제공하기로 했어요.

의존성은 패키지 개별 단위로 나뉘지 않아요

처음에는 패키지를 각각 번들링해서 S3에 올려 두고, 프로젝트가 필요한 패키지만 골라 import map으로 연결하려고 했어요. 패키지 매니저가 어떤 문제를 풀어 주는지 충분히 이해하지 못하고 선택한 방법이었어요.

각각 번들링하는 이 방식에서는 패키지 조합에 따라 런타임 오류가 생길 수 있어요. 예를 들어 react-domreact에 의존하는데, react-dom을 따로 번들링하면 그 안에 react 코드가 함께 포함돼요. React처럼 모두가 하나의 인스턴스를 공유해야 하는 패키지를 사용할 때, 앱이 참조하는 React와 react-dom이 참조하는 React 인스턴스가 달라지면서 오류가 생기는 거예요.

이렇게 의존성은 패키지 한 개 단위로 깔끔하게 나뉘지 않아요. 어떤 패키지가 싱글톤으로 유지되어야 하는지는 그 패키지만 봐서는 알 수 없고, 함께 설치되는 패키지를 같이 봐야 알 수 있어요. 개별 패키지가 아니라 조합 단위로 관리해야 했어요.

모든 프로젝트에 조합 하나로는 부족해요

처음에는 모든 프로젝트가 같은 버전의 React와 몇 가지 패키지 조합을 고정해서 사용했어요. 이 방법은 초기 단계에는 충분했지만 오래가지는 못했어요. 사용자마다 필요한 패키지가 달라서 추가 요청이 계속 들어왔고, 조합 하나를 모든 프로젝트가 공유하다 보니 패키지를 추가할 때마다 기존 패키지들과 호환되는지를 전체 프로젝트 기준으로 확인해야 했어요. 패키지 버전을 올릴 때도 마찬가지로, 새 버전이 모든 프로젝트에 한꺼번에 적용되기 때문에 일부 프로젝트에만 필요한 업그레이드를 해 줄 방법이 없었어요.

그래서 프로젝트마다 다른 패키지 조합을 사용할 수 있는 구조가 필요했어요.

조합에 이름 붙이기

package.jsondependencies 전체를 하나의 조합으로 묶고 packageSetHash라는 이름을 붙여 package.json의 사용자 정의 필드로 기록했어요. 패키지의 public entry 목록과 설치 결과로 나온 lockfile의 해시를 함께 해싱했어요.

/**
 * 패키지 조합 산출물의 식별자를 계산해요.
 * 1. 브라우저에 공개하는 entry 목록
 * 2. Yarn이 확정한 lockfile
 * 두 입력을 기준으로 같은 패키지 조합인지 판별해요.
 */
export function computePackageSetHash(
  entrySpecifiers: readonly string[],
  lockfileHash: string,
): string {
  // sha256 해싱 후 앞 16자를 사용해서 식별값을 만들어요.
  return sha16(
    JSON.stringify({
      // entry 순서가 달라도 같은 결과가 나오도록 정렬해요.
      entries: [...entrySpecifiers].sort(),
      // lockfile의 sha256 해시예요.
      // 실제로 설치된 전이 의존성 버전까지 반영하기 위해 사용해요.
      lockfileHash,
    }),
  );
}

entry 목록을 정렬해서 넣기 때문에 package.json에 적힌 순서가 달라도 같은 해시가 나와요. 반대로 패키지가 하나라도 바뀌면 lockfile이 바뀌면서 해시도 함께 바뀌어요. 같은 조합인지 문자열로 판별할 수 있게 됐어요.

조합을 만들고 사용하기

패키지 조합은 다음과 같이 준비해요. 먼저 빈 워크스페이스를 만들고 사용자 프로젝트의 package.json을 복사해요. 그다음 yarn install을 실행해서 node_modulesyarn.lock을 얻어요. 이렇게 확정된 조합을 Vite로 빌드해서 브라우저용 패키지 파일로 만들고, 앞에서 소개한 import map도 importmap.json 파일로 함께 만들어 S3의 packageSetHash 경로에 업로드해요.

{
  "imports": {
    "react": "react_27397108.js",
    "react-dom": "react-dom_de4f2df1.js",
    "date-fns": "date-fns_1a01cd03.js"
  }
}

여기서 중요한 점은 패키지를 실제로 설치한다는 거예요. peerDependencies 해석과 버전 충돌 확인은 패키지 매니저가 원래 하던 방식대로 처리해요. 복잡한 의존성 해석 규칙을 다시 구현할 필요가 없어졌어요.

준비해 둔 패키지를 브라우저에서 사용하는 방식은 더 단순해요. 프로젝트의 package.json에서 packageSetHash를 읽고, 해시가 가리키는 경로에서 importmap.json을 받아 와요. 이 import map이 앞에서 external로 남겨 둔 import specifier를 실제 파일에 연결해 줘요.

같은 조합을 사용하는 프로젝트는 해시도 같아요. 해당 해시 경로에 패키지 파일이 이미 있으면 다시 만들지 않고, 브라우저도 Cache-Control에 따라 같은 조합의 패키지 응답을 캐시해 재사용해요.

3️⃣ 화면에 보여 주기

패키지와 앱 코드 번들이 모두 준비됐어요. 이제 이걸 실행해서 화면에 보여 줘야 해요.

처음에는 HMR(Hot Module Replacement)처럼 바뀐 모듈만 갈아 끼우는 방식을 검토했어요. 개발 중 자주 사용하는 익숙한 방식이고, 편집할 때마다 변경이 필요한 부분만 다시 그린다는 점이 좋아 보였어요.

하지만 esbuild-wasm은 HMR 기능을 제공하지 않아요. HMR을 구현하려면 어떤 모듈이 변경됐고 그 영향이 어디까지 미치는지 모듈 그래프의 상태를 직접 추적하고 계산해야 했어요. 게다가 TOI는 어드민을 만드는 도구라서, 사용자는 Preview에서 방금 작성한 코드가 화면에 어떻게 나타나는지만 확인하면 돼요. 편집 상태를 이어 가며 조작할 일이 없으니 HMR로 얻는 이점이 크지 않았어요.

그래서 부분 교체 대신 문서 전체를 교체하기로 했어요. 빌드가 끝나면 새 HTML 문서를 만들어 앱을 처음부터 실행하고, iframe을 통째로 갈아 끼워요. 이전 문서의 DOM, React 컴포넌트 상태, 모듈의 전역 값은 모두 새 문서에 영향을 주지 않아요.

문서 전체를 교체하면서 Preview 업데이트는 일종의 트랜잭션 커밋(transactional commit)처럼 동작하게 됐어요. 실행에 실패한 번들은 커밋되지 않으므로 Preview에는 실행에 성공한 가장 최신 화면만 나타나요. 빌드가 실패하거나 완성된 번들을 실행하는 중에 오류가 나면 에러 오버레이로 사용자에게 알려요.

에러가 발생하면 마지막 정상 화면을 배경으로 유지하고 에러 오버레이를 표시해요.

47초에서 1.3초로

Sandpack에서는 Preview를 열 때 패키지 의존성을 찾아 내려받았어요. TOI는 패키지 조합이 바뀔 때만 빌드해서 미리 업로드해 둬요. Preview를 렌더링할 때는 이미 만든 import map과 패키지 파일을 가져오기만 해요. 매번 반복하던 일을 변경이 일어나는 시점에 한 번만 하도록 옮긴 셈이에요.

Preview Runtime을 모두 구축한 이후, Preview에 첫 화면이 나타나기까지의 시간은 1.3초로 측정됐어요.

코드가 제품이 되게 하는 일

어드민 하나를 만들려면 프로젝트 스캐폴딩, 빌드 도구 선택, 인증, 배포, 데이터 마스킹, 호출 로그 기록 등 많은 작업이 필요해요. TOI 사용자는 이 일을 더 이상 직접 할 필요가 없지만, 작업 자체가 없어진 건 아니에요. 어드민 정책은 TOI 플랫폼이 다루고, React 코드는 AI가 생성해요. 패키지는 미리 빌드해서 준비해 두고, 코드의 빌드와 실행은 브라우저의 Preview Runtime이 맡아요.

처음에는 AI가 만든 코드를 어딘가에서 실행하기만 하면 된다고 단순하게 생각했어요. Dev Server와 Sandpack으로 코드를 실행하는 데는 성공했지만, 정말 필요한 건 실행만이 아니었어요. 여러 사용자가 서로 격리된 실행 환경에서 각각의 서비스를 편집할 수 있어야 했고, 프로젝트마다 다른 패키지를 쓸 수 있어야 했어요. 좋은 사용자 경험을 유지할 만큼 충분히 빨라야 했고요.

이 시스템을 구축하기 위해 모든 것을 새로 만들지는 않았어요. 앱 코드 빌드는 esbuild-wasm, 의존성 설치와 버전 해석은 Yarn, 패키지 사전 빌드는 Vite가, 사용하는 패키지 연결은 브라우저 import map이 맡았어요. TOI에서 한 일은 이 기능들을 새로 만드는 것이 아닌, 제품의 요구사항을 고려해서 도구들을 잘 조합하고 문제를 풀어내는 것이었어요.

AI로 코드를 만드는 비용이 낮아질수록, 제품을 만드는 데 정말 중요한 일이 무엇인지 더 잘 드러나요. TOI에서는 어드민이 지켜야 할 정책을 플랫폼이 보장하도록 만들고, 만들어진 코드를 안전하게 실행할 구조를 설계하는 일이었어요. 코드를 만드는 일은 AI가 대신할 수 있게 됐지만, 제품의 가치를 정의하고 좋은 사용자 경험을 만드는 일은 여전히 엔지니어의 역할이에요. TOI에서 코드는 AI가 만들었지만, 그 코드를 제품으로 만든 것은 엔지니어였어요.

뉴스레터가 발행되면
이메일로 알려드릴게요
구독하기