Logan.

2026-05-20

AWS SDK가 SSO 토큰 캐시를 끌고 들어왔다 publish 직전에 발이 걸린 이야기

umbrella 패키지가 webpack에서 node:fs를 끌고 들어와 런타임을 깨뜨린 사고, 그리고 라이브러리를 외부에서 어떻게 쓰는지에 대한 솔직한 안내.

by Logan·20분 읽기·part 3 of Visual Builder OSS(Open Source Software) 분리기·#oss #visual-builder #aws-sdk #webpack #npm-publish #react-native-web

안녕하세요, 로건입니다 👋

2편 끝에서 살짝 언급했던 함정 이야기로 시작할게요. publish 직전에 발을 걸리게 한 바로 그 함정이요.

node:fs.promises.writeFile이 갑자기 보이던 날

OSS의 createS3Storage가 기존 프로젝트 webpack 위에서 처음으로 돌아간 날이었어요. 빌드는 멀쩡히 통과했어요. 그런데 storybook을 열어보니 콘솔에 이런 게 뜨더라고요.

TypeError: Cannot destructure property 'writeFile' of
'node_fs__WEBPACK_IMPORTED_MODULE_1__.promises' as it is undefined.

fs.promises.writeFile. 처음엔 황당했어요. 우리는 그런 코드를 짠 적이 없거든요. 어디서 누가 fs를 import하고 있단 말이에요?

스택을 따라가니까 길이 이쪽으로 모였어요.

@bdmakers/visual-builder/storage-s3 (CJS dist)
 └─ @aws-sdk/credential-providers ← umbrella
 └─ @aws-sdk/credential-provider-sso
 └─ @aws-sdk/token-providers (SSO token cache)
 └─ node:fs.promises.writeFile

원인은 umbrella 패키지의 fan-out이었어요. @aws-sdk/credential-providers라는 이름이 단수형 같지만, 사실 이건 umbrella예요. SSO, INI, node, process, web-identity… AWS의 모든 credential provider를 한꺼번에 re-export하는 합본 모듈. 우리가 거기서 가져다 쓰는 건 fromCognitoIdentityPool 딱 하나인데, 합본이 다 같이 따라온 거예요.

ESM이라면 tree-shake가 살려줬을 텐데, OSS의 storage-s3가 CJS dist를 통과해 기존 프로젝트 webpack 5의 tree-shake로 들어가면서 그 길이 막힌 거예요. CJS re-export 사이에서는 tree-shake가 잘 안 통하거든요. 미사용 provider 코드까지 묶여 들어가고, 그 중 SSO token cache가 런타임에 fs.promises.writeFile을 부르려고 시도하는데 — 기존 프로젝트 webpack은 fallback: { fs: false }로 노드 모듈을 막아뒀단 말이에요. 그래서 fs.promises 자체가 undefined였고, 거기서 destructure하려다 터진 거였어요.

일단 기존 프로젝트를 살려두기 위한 임시 처치

publish 일정이 코앞이었어요. OSS 쪽 fix를 깔끔하게 내놓을 시간이 없어서, 일단 기존 프로젝트를 살려두는 방향으로 갔어요. 미사용 8개 provider 경로를 webpack alias로 noop 처리해버렸어요.

const UNUSED_AWS_CREDENTIAL_PATHS = [
  "@aws-sdk/token-providers",
  "@aws-sdk/credential-provider-sso",
  "@aws-sdk/credential-provider-ini",
  "@aws-sdk/credential-provider-node",
  "@aws-sdk/credential-provider-process",
  "@aws-sdk/credential-provider-web-identity",
  "@aws-sdk/credential-provider-env",
  "@aws-sdk/credential-provider-http",
];

기존 프로젝트 commit 9857fd2d. 일단 돌아가긴 했어요. 그런데 짠 다음 날 잠깐 생각해보니 이건 답이 아니더라고요. 외부에서 @bdmakers/visual-builder/storage-s3를 깐 사람한테 "기존 프로젝트처럼 alias 8개를 webpack에 박으세요"라고 시킬 순 없잖아요. 그건 OSS가 자기 임차료를 사용자에게 떠넘기는 모양이에요. 내가 짠 OSS가 다른 OSS의 똥을 사용자에게 던지는 느낌? 그건 안 됐어요.

OSS 본체에서 풀기

그래서 OSS 쪽으로 가서 fix를 만들었어요. import 한 줄만 바꿨어요.

- import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";
+ import { fromCognitoIdentityPool } from "@aws-sdk/credential-provider-cognito-identity";

umbrella가 아니라 cognito-only 단일 패키지에서 직접 import. 이렇게 바꾸니까 SSO/INI/process/web-identity 일가가 의존 트리에 애초에 들어오지 않게 됐어요. 0.1.0 첫 릴리스를 내자마자 바로 0.1.1 hotfix로 내보냈어요. 릴리스 노트는 솔직하게 적었어요 — bundlers that can't tree-shake the CJS re-exports end up dragging node:fs.promises.writeFile into consumer builds.

기존 프로젝트는 v0.1.1로 올리고 alias 8개를 통째로 걷어냈어요. package.json의 dep도 cognito-only 패키지로 교체. 기존 프로젝트 yarn.lock에서 124줄이 삭제되고 3줄이 들어왔어요. umbrella가 끌고 들어오던 일가가 한 번에 사라진 거죠.

4ad1b0d7 refactor(storybook): SSO/INI noop alias 제거 — OSS v0.1.1 fan-out fix로 불요
11e93395 chore(deps): aws-sdk credential-providers umbrella → cognito-identity 단일 패키지

이 일에서 배운 한 가지는 단순해요. umbrella 패키지는 ESM 환경 밖에서 쓸 때 조심하자. 특히 CJS dist + webpack 5 조합이면 tree-shake가 안 통할 가능성이 높고, 그러면 사용하지도 않는 provider가 번들에 묶여 들어가 런타임을 깨뜨릴 수도 있어요. AWS SDK v3에서 S3 + Cognito 조합만 쓴다면 반드시 @aws-sdk/credential-provider-cognito-identity 단일 패키지로.

잠깐, PAT scope 함정도 있었어요

이건 함정이라기보다는 잠깐 멈추게 만드는 한 칸. OSS repo에 GitHub Actions CI yml을 push하는데 이런 에러가 떴어요.

! [remote rejected] main -> main
(refusing to allow a Personal Access Token to create or update workflow
 `.github/workflows/ci.yml` without `workflow` scope)

일반 repo scope만으로는 거부돼요. GitHub의 보안 정책상 .github/workflows/*.yml push는 PAT에 workflow scope이 명시돼야 통과해요. PAT 편집에서 scope 하나 추가하면 같은 토큰 값으로 그대로 통과돼요. 발급은 한 번이면 되니까, 한 번 잘 발급해두면 됩니다.

함정 이야기는 여기까지

남은 분량은 라이브러리를 외부에서 어떻게 쓰는지에 대한 이야기예요. 기존 프로젝트 케이스가 RN 앱의 storybook(=RN-Web)이라 좀 특수한 환경이라서, 일반 React 웹부터 차례로 풀어볼게요.

라이브러리는 npm 패키지 하나로 떨어져 있지만 안에 sub-entry가 네 개로 나뉘어 있어요. 필요한 것만 import하면 무거운 peer dep이 번들에 안 묶이도록 의도된 설계.

  • @bdmakers/visual-builder/core — 도메인 타입, 알고리즘, code generator. React 무관.
  • @bdmakers/visual-builder/react — Provider, 패널, 훅, 렌더러. peer로 React.
  • @bdmakers/visual-builder/storage-s3 — S3 + Cognito 어댑터. optional peer로 AWS SDK 두 개.
  • @bdmakers/visual-builder/storage-memory — In-memory 어댑터. peer 없음.

루트 엔트리(@bdmakers/visual-builder)는 storage-s3만 빼고 다 re-export해요. S3를 안 쓰는 사용자한테 AWS SDK가 안 묶이게 의도적으로 갈라둔 거예요.

새 React 웹 프로젝트 — 5분 안에

가장 빠른 길은 메모리 어댑터로 시작하는 거예요. 새로고침하면 휘발되긴 하지만, 빌더 UI가 어떻게 도는지 보기엔 충분해요.

npm install @bdmakers/visual-builder react react-dom
import {
  VisualBuilderProvider,
  Canvas,
  Palette,
  PropertiesPanel,
} from "@bdmakers/visual-builder/react";
import { createMemoryStorage } from "@bdmakers/visual-builder/storage-memory";
import type { ComponentRegistry } from "@bdmakers/visual-builder/core";
 
import { Button } from "./components/Button";
 
const components: ComponentRegistry = {
  defs: [
    {
      type: "Button",
      label: "Button",
      category: "Inputs",
      icon: "□",
      acceptsChildren: false,
      hasTextContent: true,
      defaultProps: { variant: "primary" },
      defaultTextContent: "Click me",
      propDefs: [
        {
          key: "variant",
          label: "Variant",
          type: "select",
          options: [
            { value: "primary", label: "Primary" },
            { value: "ghost", label: "Ghost" },
          ],
          defaultValue: "primary",
        },
      ],
    },
  ],
  resolve: (type) =>
    (({ Button }) as Record<string, React.ComponentType<any>>)[type],
  imports: { Button: { path: "./components/Button", isDefault: false } },
  noOnPressTypes: new Set(),
};
 
export default function App() {
  return (
    <VisualBuilderProvider
      components={components}
      storage={createMemoryStorage()}
    >
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "240px 1fr 320px",
          height: "100vh",
        }}
      >
        <Palette />
        <Canvas />
        <PropertiesPanel />
      </div>
    </VisualBuilderProvider>
  );
}

이 한 파일이 굴러갑니다. OSS repo의 examples/minimal-react에 vite로 묶은 동작 데모가 있으니, 거기서 그대로 돌려볼 수도 있어요.

운영용으로 가려면 — S3 + Cognito

저장이 휘발성이면 운영엔 못 써요. 기존 프로젝트가 운영 중인 방식 그대로, S3 + Cognito Identity Pool 조합으로 서버 없이 영구 저장이 가능해요.

npm install @aws-sdk/client-s3 @aws-sdk/credential-provider-cognito-identity

위에서 한참 떠들었던 것 — @aws-sdk/credential-providers umbrella는 절대 깔지 마세요. cognito-only 단일 패키지로.

import { createS3Storage } from "@bdmakers/visual-builder/storage-s3";
 
const storage = createS3Storage({
  bucket: "my-designs",
  region: "ap-northeast-2",
  identityPoolId: "ap-northeast-2:xxxx-xxxx-xxxx-xxxx",
  publicBaseUrl: "https://my-designs.s3.amazonaws.com",
});

또는 환경변수가 셋업돼 있으면 인자 없이도 동작해요. OSS는 VISUAL_BUILDER_S3_BUCKET, _REGION, _COGNITO_POOL_ID, _S3_PUBLIC_BASE_URL 네 개를 자동으로 읽어요.

전제는 Cognito Unauthenticated 모드예요. 별도 백엔드 없이 브라우저가 직접 S3에 PUT/GET. Identity Pool의 unauth role에 IAM 권한이 좀 필요해요.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET/designs/*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucketVersions"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET",
      "Condition": { "StringLike": { "s3:prefix": "designs/*" } }
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET/designs/*"
    }
  ]
}

그리고 S3 bucket CORS도 비슷하게.

[
  {
    "AllowedOrigins": ["https://your-app.example.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "x-amz-version-id"]
  }
]

이게 다 걸리면 빌더가 만든 디자인이 JSON으로 S3에 저장돼요. 그리고 공짜로 — S3 versioning이 켜져 있으면 버전 히스토리가 따라와요. 운영 비용 거의 0. 기존 프로젝트가 이 모양으로 굴러가고 있어요.

RN 앱의 storybook에서 — 기존 프로젝트 케이스

기존 프로젝트가 정확히 이 케이스예요. RN 앱이지만 storybook은 web(RN-Web) 환경에서 굴러요. OSS 자체는 일반 React 라이브러리라 web에서 그대로 동작하는데, RN 컴포넌트 중에 web에서 못 도는 친구가 많아서 webpack shim이 한 번씩 필요해요.

기존 프로젝트 .storybook/web/main.ts의 핵심 패턴이 이렇게 생겼어요.

// 1) RN 전용 모듈을 noop 또는 web shim으로 alias
const NATIVE_ONLY_MODULES = [
  "react-native-code-push",
  "@invertase/react-native-apple-authentication",
  "@gorhom/bottom-sheet",
  "react-native-onesignal",
  // ... 70여 개
];
for (const mod of NATIVE_ONLY_MODULES) {
  config.resolve.alias[mod] = noopModule;
}
 
// JSX에 직접 들어가는 RN 모듈은 noop 대신 web 호환 shim으로
config.resolve.alias["lottie-react-native"] = lottieViewShim;
config.resolve.alias["react-native-modal"] = rnModalShim;
config.resolve.alias["react-native-linear-gradient"] = linearGradientShim;
 
// 2) AWS SDK의 node: scheme 처리 (storage-s3를 쓰는 경우)
config.plugins.push(
  new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => {
    resource.request = resource.request.replace(/^node:/, "");
  }),
);
 
// 3) Buffer / process 폴리필
config.resolve.fallback = {
  buffer: require.resolve("buffer/"),
  process: require.resolve("process/browser"),
  stream: false,
  crypto: false,
  http: false,
  https: false,
  url: false,
  fs: false,
  "fs/promises": false,
};
config.plugins.push(
  new webpack.ProvidePlugin({
    Buffer: ["buffer", "Buffer"],
    process: "process/browser",
  }),
);

이 셋업이 한 번 깔리면, OSS 사용 자체는 위 React 웹 케이스랑 똑같아요. RN 기존 프로젝트의 디자인 시스템을 RN-Web 위에서 빌더로 합성하고, 결과를 S3에 저장 — 기존 프로젝트가 정확히 이 모양으로 굴러가는 중.

(이 부분은 사실 다음 글로 따로 풀어내고 싶긴 해요. 기존 프로젝트 webpack shim 셋업 자체가 다른 RN-Web 프로젝트가 참고할 만한 패턴이거든요. 지금은 기존 프로젝트 main.ts 안에 인라인 주석으로만 설명돼 있어요.)

RN 앱 본체에서 쓸 수는 없나요

이건 솔직한 한계예요. v0.1의 react sub-entry는 DOM API를 직접 호출해요. getBoundingClientRect, pointer events 같은 것들. 그러니까 RN 본체에서 import하면 런타임 에러가 나요. 본체에서 안전하게 쓸 수 있는 건 core 하나뿐이에요.

import {
  generateCodeWith,
  generateFullSourceWith,
  moveNode,
  insertNode,
  snapToToken,
  type CanvasNode,
  type ComponentRegistry,
} from "@bdmakers/visual-builder/core";
 
// 알고리즘이랑 코드 생성기는 RN에서도 그대로 돌아요
const updated = moveNode(nodes, sourceId, target);
const fullSource = generateFullSourceWith({
  componentName,
  nodes,
  components,
  tokens,
});

빌더 UI 자체를 RN 본체에서 굴리려면 v0.4를 기다려야 해요. RN gesture handler + reanimated 기반으로 패널 UI를 재구현할 계획이에요. 그게 끝나면 RN-Web 경유 말고 진짜 RN 앱 안에서 빌더가 돌아요.

기존 프로젝트는 어떤 모양으로 정착했는가

분리 후 기존 프로젝트의 Playground/visualBuilder/ 폴더는 이렇게 정착했어요.

visualBuilder/
├── componentDefs.ts ← 기존 프로젝트 컴포넌트 카탈로그 (70+)
├── tokenOptions.ts ← 기존 프로젝트 디자인 토큰
├── styles.ts ← 기존 프로젝트 빌더 UI의 styled-components
├── types.ts ← OSS core 타입 re-export
├── index.ts ← 기존 프로젝트 caller가 보는 단일 export 표면
└── registries/
 ├── types.ts ← OSS core 어댑터 타입 re-export
 ├── appComponents.tsx ← ComponentRegistry 어댑터
 ├── appTokens.ts ← TokenRegistry 어댑터
 └── appS3.ts ← createS3Storage 인스턴스 (env 매핑)

기존 프로젝트 caller는 OSS 패키지를 직접 import하지 않아요. 이 index.ts 하나만 import하면 OSS랑 기존 프로젝트 어댑터가 묶여서 같이 떨어져요.

import {
  appComponentRegistry,
  appTokenRegistry,
  appS3Storage,
  generateFullSourceWith,
  renderNodeWith,
  moveNode,
  insertNode,
} from "src/stories/Playground/visualBuilder";

OSS의 API가 마이너로 바뀌어도 이 단일 파일만 갱신하면 기존 프로젝트 다른 곳에 영향이 안 와요. 분리의 마지막 선물이에요.

회사 OSS 트레이드오프 — 짧게

마지막으로 한 가지만. 분리를 회사 OSS 쪽으로 가져간 결정에 대해서는 솔직하게 적어두고 싶어요.

MIT 라이선스로 풀면 코드 자체는 누구나 fork할 수 있어요. 그런데 패키지 이름은 영구히 회사 소유예요. 회사를 떠나면 publish 권한을 잃고, @bdmakers/visual-builder라는 이름으로 새 버전을 못 올려요. 코드는 들고 갈 수 있어도 이름은 못 들고 가요.

저는 이번엔 그게 트레이드오프할 만했다고 봤어요. 회사가 이미 다른 OSS를 그 scope에 운영 중이었고, OSS화 자체에 명시적으로 동의가 된 상태였고, 사내 OSS 카탈로그에 묶이면 발견성도 좋아지고. 그런데 이 결정 자체는 단순하지 않으니까, 비슷한 분리를 고민하는 분이 있다면 분리하기 전에 회사 쪽 동의 범위를 분명히 해두는 게 좋아요. 그 한 마디가 나중에 곤란해지는 걸 미리 막아줘요.

앞으로

v0.1.1이 떨어진 지금, 다음 버전 로드맵은 이래요.

v0.2에서 CLI scanner를 넣을 계획이에요. npx @bdmakers/visual-builder scan ./src/components로 컴포넌트 디렉토리를 스캔해서 ComponentRegistry 초안을 자동 생성하는 도구. 기존 프로젝트처럼 70+ 컴포넌트가 있는 환경에서는 등록 boilerplate가 등록 후 직접 검토 한 번만 거치면 끝나는 정도까지는 줄여보려고요.

v0.3에서는 Storybook 통합. 기존 *.stories.tsxargTypes를 자동으로 propDefs로 변환하는 autoRegisterFromStorybook. argTypes를 이미 잘 적어둔 프로젝트는 빌더 카탈로그를 따로 안 적어도 되겠죠.

v0.4가 RN 본체 지원이에요. @bdmakers/visual-builder/react-native 별도 sub-entry로, RN gesture handler + reanimated 기반의 패널 UI 재구현.

그리고 v1.0에서 API 동결. 그 전까지는 0.x patch/minor 사이에 breaking change가 들어올 수 있으니까, 정확한 버전 핀을 권장해요("@bdmakers/visual-builder": "0.1.1").

시리즈를 닫으며

1편에서는 어디가 박혀 있는지를 그리고, 어댑터 세 개로 의존 방향을 뒤집기로 결정했어요. 그 자리에서 가장 오래 고민한 건 transformResolvedProps 같은 escape hatch를 어디에 둘지였어요. OSS 본체에 박으면 코드는 깔끔해지지만 OSS가 우리 회사 디자인 시스템 모양에 가까워지니까, 그건 OSS답지 않더라고요.

2편에서는 순서를 뒤집은 이유. OSS 본체부터 만들면 회귀가 떴을 때 OSS인지 기존 프로젝트 어댑터인지 흐려져요. 그래서 기존 프로젝트 안에서 먼저 어댑터 모양으로 갈아엎고, Chromatic 회귀 0건을 통과시킨 다음, 검증된 코드를 OSS로 옮기는 흐름. 이 두 겹 안전망(단위 테스트 + Chromatic)이 살아 있었기 때문에 한 영역씩 과감하게 분리할 수 있었어요.

3편에서는 두 함정 — umbrella가 끌고 들어온 SSO 토큰 캐시, 그리고 GitHub과 npm의 이름 불일치 — 을 풀고, 외부 사용법까지. 함정은 분명히 있었지만 기존 프로젝트 alias로 임시 처치한 부분을 결국 OSS 본체로 옮겨서 사용자에게 짐이 가지 않게 정리했어요.

8,400 라인의 도구가 npm 라이브러리 하나로 떨어지고, 기존 프로젝트는 어댑터 wrapper 300줄 정도만 들고 있는 모양. 그 사이에 시각 회귀 0건. 외부 사용자는 npm install 한 번으로 같은 도구를 쓸 수 있고, 자기 디자인 시스템·토큰·storage를 주입만 하면 돼요.

여전히 Pre-alpha v0.1.x고, 0.x 사이에 minor breaking change 가능성은 남아 있어요. 그래도 이 시점까지 — 의존 방향을 뒤집고, 함정 두 개를 수습하고, 기존 프로젝트를 어댑터 모양으로 단정하게 남겨둔 — 2주일을 한 번 정리해두고 싶었어요.

읽어주셔서 감사합니다. :)

related — in series · Visual Builder OSS(Open Source Software) 분리기