TuBrief
구독 채널
비디오
커뮤니티

Vercel Sandbox에서 코드 실행할 때 과금 폭탄 맞지 않는 법

TuBrief 편집팀
2026년 7월 23일
0
컴퓨터/소프트웨어

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

한국어EnglishEspañol中文العربيةहिन्दीDeutschFrançaisPortuguêsРусскийBahasa Indonesia日本語

관련 영상

Ship 26 NYC - FLORA의 프로덕션 환경 샌드박스15:15

Ship 26 NYC - FLORA의 프로덕션 환경 샌드박스

Vercel

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

Vercel Sandbox에서 코드 실행할 때 과금 폭탄 맞지 않는 법

LLM이 만든 코드를 서버에서 돌리는 건 언제나 찝찝합니다. 어디로 튀어 나갈지 모르는 코드를 감싸려면 격리된 공간이 필수죠. Vercel Sandbox는 Firecracker microVM을 깔끔하게 포장해서 던져주지만, 아무 생각 없이 가져다 쓰면 요금서와 레이턴시에 두 번 맞습니다.

무작정 돌렸다가 통장 깨지기 딱 좋은 과금 구조부터, 16초씩 걸리는 콜드 스타트 지연, 컨텍스트를 갉아먹는 MCP 서버 문제까지 실제 현장에서 바로 쓸 수 있는 해결책을 정리했습니다.

요금표의 행간과 16초짜리 콜드 스타트 지우기

Vercel Sandbox 요금표를 보면 Active CPU와 Provisioned Memory가 따로 놉니다. Pro 플랜 기준 Active CPU는 vCPU-시간당 $0.128인데, 밀리초 단위로 잽니다. 흥미로운 건 외부 LLM 응답을 기다리거나 DB 쿼리를 던져놓고 멍때리는 I/O Wait 시간엔 CPU 과금이 0원이라는 점입니다.

진짜 문제는 메모리입니다. Provisioned Memory는 할당한 용량 기준으로 샌드박스가 살아있는 전체 벽시계 시간(Wall-clock time) 동안 GB-시간당 $0.0212를 뗍니다. 최소 1분 단위 올림 정산이라, 5초 쓰고 방치해도 1분 치가 날아갑니다. 여기에 인스턴스 생성비 1,000,000회당 $0.60가 붙죠.

[사용자 요청] -> [Sandbox 생성 ($0.60/100만 회)]
      |
      +--> Active CPU: vCPU-시간당 $0.128 (ms 단위, I/O Wait $0 처리)
      +--> Provisioned Memory: GB-시간당 $0.0212 (벽시계 시간, 1분 올림)
      |
[작업 완료] -> [sandbox.stop() 명시적 호출] -> [메모리 과금 즉시 중단]

아무 설정 없는 생 컨테이너에 TypeScript, Zod, Axios를 npm으로 지어 올리면 부팅에만 16.49초가 터집니다. 실시간 서비스라면 이미 유저가 이탈하고도 남을 시간이죠. 인스턴스 캐스케이드와 스냅샷을 엮어야 500ms 밑으로 떨어집니다.

  1. 베이스 스냅샷 고정: 의존성을 미리 깔아둔 컨테이너에서 sandbox.snapshot()을 올려 파일시스템을 고정합니다.
  2. Warm 인스턴스 인출: 새로 요청이 올 때 이 snapshotId를 찍어서 올립니다. 부팅 시간이 410ms로 줄어듭니다.
  3. 손절 타이밍 명시: 코드 실행이 끝나면 즉시 sandbox.stop()을 찌릅니다. 이거 안 하면 유휴 시간 메모리 요금이 그대로 쌓입니다. 이 구조만 잡아도 연산 비용이 40%는 날아갑니다.
import { Sandbox, Snapshot } from '@vercel/sandbox';
import { config } from 'dotenv';

config({ path: '.env.local' });

export class SandboxWarmPoolManager {
  private static cachedSnapshotId: string | null = null;

  public async getOrCreateBaseSnapshot(): Promise<string> {
    if (SandboxWarmPoolManager.cachedSnapshotId) {
      return SandboxWarmPoolManager.cachedSnapshotId;
    }

    const sandbox = await Sandbox.create({
      runtime: 'node24',
      timeout: 5 * 60 * 1000,
    });

    try {
      await sandbox.runCommand({
        cmd: 'npm',
        args: ['install', 'typescript', 'zod', 'axios', 'lodash'],
      });

      const snapshot = await sandbox.snapshot({
        expiration: 14 * 24 * 60 * 60 * 1000,
      });

      SandboxWarmPoolManager.cachedSnapshotId = snapshot.snapshotId;
      return snapshot.snapshotId;
    } finally {
      await sandbox.stop();
    }
  }

  public async acquireWarmSandbox(tenantId: string): Promise<Sandbox> {
    const snapshotId = await this.getOrCreateBaseSnapshot();

    const sandbox = await Sandbox.create({
      source: { type: 'snapshot', snapshotId },
      timeout: 2 * 60 * 1000,
      resources: { vcpus: 2 },
      tags: { tenantId },
    });

    return sandbox;
  }

  public async safeRelease(sandbox: Sandbox): Promise<void> {
    try {
      await sandbox.stop();
    } catch (error) {
      console.error(`Sandbox termination failed: ${sandbox.name}`, error);
    }
  }
}

MCP 툴이 늘어날수록 먹통이 되는 프롬프트 구조 뒤집기

Model Context Protocol(MCP) 쓰다 보면 금방 벽에 부딪힙니다. 서버에 툴을 하나씩 얹을 때마다 JSON Schema가 프롬프트 상단을 채우기 시작하거든요. 툴 하나가 550~1,400 토큰을 먹습니다. MCP 서버 몇 개 연결해서 툴 50개 넘어가면, 정작 유저 질문은 받지도 못했는데 55,000 토큰이 증발합니다. 토큰 값도 값이지만 LLM이 헷갈려서 이상한 매개변수를 지어내기 시작하죠.

비교 항목 전통적인 사전 주입(Upfront) 방식 단일 오케스트레이터 Code Mode 방식
컨텍스트 사전 소비량 30,000 ~ 55,000+ 토큰 메타 툴 스키마 전용 ~800 토큰
다단계 작업 라운드트립 12 ~ 19회 순차 LLM 호출 단일 코드 블록으로 4회 이하 집행
응답 속도 및 비용 높은 토큰 비용, 3~5초 레이턴시 토큰 소비 58% 절감, 속도 2초 이상 단축
매개변수 추론 정확도 스키마 밀도 증가로 환각 발생 TypeScript 타입 검사로 정확한 전달

수십 개 스키마를 프롬프트에 나열하는 건 멍청한 짓입니다. 샌드박스 안에 TypeScript 오케스트레이터를 올려두고 최소한의 인터페이스만 보여주는 식으로 바꿔야 합니다.

  1. 메타 툴 3개만 노출: 모듈 목록 조회, TypeScript 타입 정의 수집, 작성된 코드 실행. 딱 이 3개 메타 툴만 프롬프트에 내어줍니다.
  2. 필요할 때만 핫로딩: 샌드박스 내부 런타임이 요청을 받아서 필요한 모듈 타입만 그때그때 가져와 얹습니다.
  3. 루프를 코드로 압축: LLM이 조건문과 반복문이 들어간 TypeScript 코드를 짜게 만듭니다. LLM과 API를 15번씩 주고받던 난장판이 단 1번의 코드 실행으로 끝납니다. 토큰 소비량 58% 줄어들고 응답 시간도 2초 이상 당겨집니다.
import { Sandbox } from '@vercel/sandbox';

interface OrchestrationRequest {
  userQuery: string;
  targetModules: string[];
  generatedCode: string;
}

export class MCPCodeOrchestrator {
  private sandbox: Sandbox | null = null;

  public async initializeOrchestrator(): Promise<void> {
    this.sandbox = await Sandbox.create({
      runtime: 'node24',
      timeout: 3 * 60 * 1000,
      resources: { vcpus: 2 },
    });

    await this.sandbox.writeFiles([
      {
        path: '/vercel/sandbox/runner.ts',
        content: Buffer.from(`
          const moduleRegistry = new Map<string, any>();

          export async function executeTask(code: string, activeModules: string[]) {
            for (const mod of activeModules) {
              if (!moduleRegistry.has(mod)) {
                const loaded = await import(\`./modules/\${mod}.js\`);
                moduleRegistry.set(mod, loaded);
              }
            }
            
            const asyncFn = new Function('registry', 'console', \`
              return (async () => {
                \${code}
              })();
            \`);
            
            return await asyncFn(moduleRegistry, console);
          }
        `),
      },
    ]);
  }

  public async dispatchExecution(request: OrchestrationRequest) {
    if (!this.sandbox) throw new Error('Orchestrator not initialized');

    const executionScript = `
      import { executeTask } from './runner.js';
      
      executeTask(${JSON.stringify(request.generatedCode)}, ${JSON.stringify(request.targetModules)})
        .then(result => console.log('__RESULT__:' + JSON.stringify(result)))
        .catch(err => console.error('__ERROR__:' + err.message));
    `;

    await this.sandbox.writeFiles([
      {
        path: '/vercel/sandbox/exec-spec.ts',
        content: Buffer.from(executionScript),
      },
    ]);

    const commandResult = await this.sandbox.runCommand({
      cmd: 'npx',
      args: ['tsx', '/vercel/sandbox/exec-spec.ts'],
    });

    const stdout = await commandResult.stdout();
    const stderr = await commandResult.stderr();

    if (commandResult.exitCode !== 0) {
      throw new Error(`Execution failed: ${stderr}`);
    }

    const resultMatch = stdout.match(/__RESULT__:(.*)/);
    return resultMatch ? JSON.parse(resultMatch[1]) : stdout;
  }
}

외부 통로 차단과 API 키 보호

남이 만든 코드를 내 서버에서 돌릴 때 가장 끔찍한 시나리오는 내부망을 훑거나 외부 C2 서버로 토큰을 털어가는 겁니다. Vercel Sandbox가 기본적으로 non-root 계정인 vercel-sandbox로 실행된다고 해서 안심하면 안 됩니다. 네트워크 경계에서 문을 잠가야 합니다.

[단계별 네트워크 방화벽 전환 흐름]

1. 패키지 설치 단계
   [Sandbox] ---> (SNI Allow: registry.npmjs.org) ---> [NPM Registry]

2. 코드 실행 및 LLM 호출 단계
   [Sandbox] ---> (SNI Allow: ai-gateway.vercel.sh + Header Injection) ---> [AI Gateway]
   * 실제 API Secret Key는 샌드박스 내부에 존재하지 않음

3. 결과물 이관 및 세션 종료 단계
   [Sandbox] ---> (S3/R2 파일 업로드) ---> [rm -rf 파일 삭제] ---> (deny-all 설정 후 파기)
  1. 동적 Egress 제어: npm 패키지 깔 때는 registry.npmjs.org만 열고, 실행할 때는 준비된 Gateway 도메인만 허용하도록 sandbox.updateNetworkPolicy()로 끊어줍니다.
  2. API 키 숨기기: 샌드박스 안 환경변수에 OpenAI나 Anthropic 키를 직접 넣어두는 건 "나 좀 털어주세요" 하는 꼴입니다. 방화벽 경계에서 x-api-key 헤더를 주입(Header Injection)하게 만들면 컨테이너 내부는 키의 존재조차 모릅니다.
  3. 파일 뽑아내고 소멸: 결과물로 나온 영상이나 파일은 Cloudflare R2나 S3로 빼낸 뒤 rm -rf로 닦아내고 deny-all로 문 닫고 종료합니다.
import { Sandbox } from '@vercel/sandbox';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

export class SecureSandboxRunner {
  private s3Client: S3Client;

  constructor() {
    this.s3Client = new S3Client({
      region: 'auto',
      endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
      credentials: {
        accessKeyId: process.env.R2_ACCESS_KEY_ID!,
        secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
      },
    });
  }

  public async runUntrustedPipeline(userCode: string, tenantId: string) {
    const sandbox = await Sandbox.create({
      runtime: 'node24',
      timeout: 5 * 60 * 1000,
      networkPolicy: {
        allow: ['registry.npmjs.org'],
      },
    });

    try {
      await sandbox.updateNetworkPolicy({
        allow: {
          'ai-gateway.vercel.sh': [
            {
              transform: [
                {
                  headers: {
                    'x-api-key': process.env.AI_GATEWAY_API_KEY!,
                  },
                },
              ],
            },
          ],
        },
      });

      await sandbox.writeFiles([
        { path: '/vercel/sandbox/index.js', content: Buffer.from(userCode) },
      ]);

      const execResult = await sandbox.runCommand('node', ['/vercel/sandbox/index.js']);
      if (execResult.exitCode !== 0) {
        throw new Error(`Execution error: ${await execResult.stderr()}`);
      }

      const mediaBuffer = await sandbox.fs.readFile('/vercel/sandbox/output.mp4');
      const storageKey = `tenants/${tenantId}/exports/${Date.now()}_output.mp4`;

      await this.s3Client.send(
        new PutObjectCommand({
          Bucket: process.env.R2_BUCKET_NAME!,
          Key: storageKey,
          Body: mediaBuffer,
          ContentType: 'video/mp4',
        })
      );

      await sandbox.runCommand('rm', ['-rf', '/vercel/sandbox/*']);

      return {
        success: true,
        mediaUrl: `https://${process.env.R2_PUBLIC_DOMAIN}/${storageKey}`,
      };
    } finally {
      await sandbox.updateNetworkPolicy('deny-all');
      await sandbox.stop();
    }
  }
}

Remotion 비디오 렌더링 시 OOM 사망 피하기

Vercel Sandbox Pro는 vCPU당 2GB RAM을 줍니다. 8 vCPU로 세팅하면 16GB라 넉넉해 보이죠. 그런데 샌드박스 안에서 Remotion으로 Headless Chromium을 띄우고 FFmpeg을 돌리면 프레임 버퍼가 메모리를 가득 채우다가 Linux OOM Killer를 만납니다. 렌더링 중간에 SIGKILL 맞고 프로세스가 터지는 이유입니다.

이 문제를 피하려면 통째로 렌더링하는 탐욕스러운 방식을 버리고 조각내서 병렬 처리해야 합니다.

  1. 캐시 크기 제한: offthreadVideoCacheSizeInBytes를 512MB 이하로 잡고, 동시 실행 수(concurrency)를 CPU 코어 수 이하로 제한합니다.
  2. 청크 단위 쪼개기: 1,800프레임짜리 영상을 150프레임 단위로 잘라 여러 샌드박스로 분산합니다. 이때 코덱은 .ts (h264-ts) 형식으로 임시 저장해야 나중에 합치기 편합니다.
  3. BullMQ 제어와 병합: Redis 기반 작업 큐로 동시 실행 수를 제어하고, 각 조각이 완성되면 @remotion/renderer의 combineChunks()로 재인코딩 없이 붙입니다.
import { Queue, Worker, Job } from 'bullmq';
import { Sandbox } from '@vercel/sandbox';
import { combineChunks } from '@remotion/renderer';
import Redis from 'ioredis';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';

const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
  maxRetriesPerRequest: null,
});

export interface RenderChunkJobData {
  compositionId: string;
  serveUrl: string;
  frameRange: [number, number];
  chunkIndex: number;
  totalChunks: number;
  fps: number;
}

export const renderQueue = new Queue<RenderChunkJobData>('video-render-queue', { connection });

export const renderWorker = new Worker<RenderChunkJobData>(
  'video-render-queue',
  async (job: Job<RenderChunkJobData>) => {
    const { compositionId, serveUrl, frameRange, chunkIndex } = job.data;

    const sandbox = await Sandbox.create({
      runtime: 'node24',
      timeout: 10 * 60 * 1000,
      resources: { vcpus: 4 },
    });

    try {
      const renderScript = `
        import { renderMedia } from '@remotion/renderer';

        async function run() {
          await renderMedia({
            composition: ${JSON.stringify({ id: compositionId })},
            serveUrl: ${JSON.stringify(serveUrl)},
            outputLocation: '/vercel/sandbox/chunk_${chunkIndex}.ts',
            frameRange: ${JSON.stringify(frameRange)},
            codec: 'h264-ts',
            audioCodec: 'pcm-16',
            enforceAudioTrack: true,
            concurrency: 2,
            offthreadVideoCacheSizeInBytes: 512 * 1024 * 1024,
          });
        }
        run().catch(err => { console.error(err); process.exit(1); });
      `;

      await sandbox.writeFiles([
        { path: '/vercel/sandbox/render-chunk.ts', content: Buffer.from(renderScript) },
      ]);

      const result = await sandbox.runCommand('npx', ['tsx', '/vercel/sandbox/render-chunk.ts']);
      if (result.exitCode !== 0) {
        throw new Error(`Chunk ${chunkIndex} failed: ${await result.stderr()}`);
      }

      const chunkBuffer = await sandbox.fs.readFile(`/vercel/sandbox/chunk_${chunkIndex}.ts`);
      return { chunkIndex, chunkData: chunkBuffer.toString('base64') };
    } finally {
      await sandbox.stop();
    }
  },
  {
    connection,
    concurrency: 4,
    limiter: {
      max: 10,
      duration: 1000,
    },
  }
);

export async function orchestrateParallelRender(
  compositionId: string,
  serveUrl: string,
  totalFrames: number,
  fps: number
): Promise<string> {
  const framesPerChunk = 150;
  const chunkPromises = [];
  const totalChunks = Math.ceil(totalFrames / framesPerChunk);

  for (let i = 0; i < totalChunks; i++) {
    const startFrame = i * framesPerChunk;
    const endFrame = Math.min((i + 1) * framesPerChunk - 1, totalFrames - 1);

    const job = await renderQueue.add(`chunk-${i}`, {
      compositionId,
      serveUrl,
      frameRange: [startFrame, endFrame],
      chunkIndex: i,
      totalChunks,
      fps,
    });

    chunkPromises.push(job.waitUntilFinished(renderWorker.opts.queueEvents!));
  }

  const results = await Promise.all(chunkPromises);
  results.sort((a, b) => a.chunkIndex - b.chunkIndex);

  const workDir = join(process.cwd(), `tmp_render_${Date.now()}`);
  mkdirSync(workDir, { recursive: true });

  const videoFiles: string[] = [];
  results.forEach((res, idx) => {
    const filePath = join(workDir, `chunk_${idx}.ts`);
    writeFileSync(filePath, Buffer.from(res.chunkData, 'base64'));
    videoFiles.push(filePath);
  });

  const finalOutputPath = join(workDir, 'final_output.mp4');

  await combineChunks({
    videoFiles,
    audioFiles: [],
    outputLocation: finalOutputPath,
    codec: 'h264',
    fps,
    framesPerChunk,
    compositionDurationInFrames: totalFrames,
  });

  return finalOutputPath;
}

Vercel Sandbox는 편리하지만 방심하면 인프라 비용과 보안 문제를 그대로 떠안게 됩니다. 스냅샷 기반 캐싱으로 서버 자원 소모를 끊고, 오케스트레이터로 프롬프트 낭비를 막는 것부터 시작해보세요.