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 밑으로 떨어집니다.
- 베이스 스냅샷 고정: 의존성을 미리 깔아둔 컨테이너에서
sandbox.snapshot()을 올려 파일시스템을 고정합니다. - Warm 인스턴스 인출: 새로 요청이 올 때 이
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);
}
}
}
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 오케스트레이터를 올려두고 최소한의 인터페이스만 보여주는 식으로 바꿔야 합니다.
- 메타 툴 3개만 노출: 모듈 목록 조회, TypeScript 타입 정의 수집, 작성된 코드 실행. 딱 이 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 설정 후 파기)
- 동적 Egress 제어: npm 패키지 깔 때는
registry.npmjs.org만 열고, 실행할 때는 준비된 Gateway 도메인만 허용하도록sandbox.updateNetworkPolicy()로 끊어줍니다. - API 키 숨기기: 샌드박스 안 환경변수에 OpenAI나 Anthropic 키를 직접 넣어두는 건 "나 좀 털어주세요" 하는 꼴입니다. 방화벽 경계에서
x-api-key헤더를 주입(Header Injection)하게 만들면 컨테이너 내부는 키의 존재조차 모릅니다. - 파일 뽑아내고 소멸: 결과물로 나온 영상이나 파일은 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 맞고 프로세스가 터지는 이유입니다.
이 문제를 피하려면 통째로 렌더링하는 탐욕스러운 방식을 버리고 조각내서 병렬 처리해야 합니다.
- 캐시 크기 제한:
offthreadVideoCacheSizeInBytes를 512MB 이하로 잡고, 동시 실행 수(concurrency)를 CPU 코어 수 이하로 제한합니다. - 청크 단위 쪼개기: 1,800프레임짜리 영상을 150프레임 단위로 잘라 여러 샌드박스로 분산합니다. 이때 코덱은
.ts(h264-ts) 형식으로 임시 저장해야 나중에 합치기 편합니다. - 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는 편리하지만 방심하면 인프라 비용과 보안 문제를 그대로 떠안게 됩니다. 스냅샷 기반 캐싱으로 서버 자원 소모를 끊고, 오케스트레이터로 프롬프트 낭비를 막는 것부터 시작해보세요.
