Vercel Sandbox에서 코드 실행할 때 과금 폭탄 맞지 않는 법
TuBrief 편집팀
2026년 7월 23일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
LLM이 만든 코드를 서버에서 돌리는 건 언제나 찝찝합니다. 어디로 튀어 나갈지 모르는 코드를 감싸려면 격리된 공간이 필수죠. Vercel Sandbox는 Firecracker microVM을 깔끔하게 포장해서 던져주지만, 아무 생각 없이 가져다 쓰면 요금서와 레이턴시에 두 번 맞습니다.
무작정 돌렸다가 통장 깨지기 딱 좋은 과금 구조부터, 16초씩 걸리는 콜드 스타트 지연, 컨텍스트를 갉아먹는 MCP 서버 문제까지 실제 현장에서 바로 쓸 수 있는 해결책을 정리했습니다.
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 밑으로 떨어집니다.
sandbox.snapshot()을 올려 파일시스템을 고정합니다.snapshotId를 찍어서 올립니다. 부팅 시간이 410ms로 줄어듭니다.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);
}
}
}
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 오케스트레이터를 올려두고 최소한의 인터페이스만 보여주는 식으로 바꿔야 합니다.
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;
}
}
남이 만든 코드를 내 서버에서 돌릴 때 가장 끔찍한 시나리오는 내부망을 훑거나 외부 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 설정 후 파기)
registry.npmjs.org만 열고, 실행할 때는 준비된 Gateway 도메인만 허용하도록 sandbox.updateNetworkPolicy()로 끊어줍니다.x-api-key 헤더를 주입(Header Injection)하게 만들면 컨테이너 내부는 키의 존재조차 모릅니다.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();
}
}
}
Vercel Sandbox Pro는 vCPU당 2GB RAM을 줍니다. 8 vCPU로 세팅하면 16GB라 넉넉해 보이죠. 그런데 샌드박스 안에서 Remotion으로 Headless Chromium을 띄우고 FFmpeg을 돌리면 프레임 버퍼가 메모리를 가득 채우다가 Linux OOM Killer를 만납니다. 렌더링 중간에 SIGKILL 맞고 프로세스가 터지는 이유입니다.
이 문제를 피하려면 통째로 렌더링하는 탐욕스러운 방식을 버리고 조각내서 병렬 처리해야 합니다.
offthreadVideoCacheSizeInBytes를 512MB 이하로 잡고, 동시 실행 수(concurrency)를 CPU 코어 수 이하로 제한합니다..ts (h264-ts) 형식으로 임시 저장해야 나중에 합치기 편합니다.@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는 편리하지만 방심하면 인프라 비용과 보안 문제를 그대로 떠안게 됩니다. 스냅샷 기반 캐싱으로 서버 자원 소모를 끊고, 오케스트레이터로 프롬프트 낭비를 막는 것부터 시작해보세요.