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

Node.js에서 Sharp 빼고 Bun 내장 기능으로 미디어 서버 돌리기

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

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

한국어Español中文العربيةFrançaisBahasa Indonesiaहिन्दीPortuguês日本語EnglishDeutschРусский

관련 영상

Bun.Image가 기존의 모든 이미지 처리 파이프라인을 대체합니다4:34

Bun.Image가 기존의 모든 이미지 처리 파이프라인을 대체합니다

Better Stack

커뮤니티의 다른 글

사내 시스템에 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
구독 채널
비디오
커뮤니티
로그인

Node.js에서 Sharp 빼고 Bun 내장 기능으로 미디어 서버 돌리기

로컬에서는 잘 돌던 도커 이미지가 배포 서버에서 빌드 에러를 뿜을 때가 있습니다. 원인을 파헤쳐 보면 범인은 십중팔구 C++ 네이티브 바인딩을 쓰는 sharp와 libvips 라이브러리입니다. 여기에 매달 수십 달러씩 빠져나가는 Cloudinary나 Imgix 결제 내역까지 보면, 이미지 처리 기능 하나 넣자고 왜 이 고생을 해야 하나 회의감이 들기 마련입니다.

외부 C++ 컴파일 단계 없이 Bun 단일 런타임의 내장 기능만으로 이미지 가공 파이프라인을 구축해 컨테이너 용량을 줄이고 클라우드 비용을 아끼는 실무 전환 방식을 정리했습니다.

Alpine 컨테이너 빌드를 터뜨리는 C++ 바인딩 걷어내기

Node.js 환경에서 sharp를 쓰면 node-gyp와 N-API 계층을 통해 운영체제의 C 라이브러리에 동적으로 연결됩니다. 가벼운 Alpine Linux 기반으로 도커 이미지를 만들려고 하면 glibc와 musl C 라이브러리 간의 비호환성 때문에 컨테이너 안에서 node-gyp rebuild가 강제로 돕니다.

이 과정에서 GCC, Python, make 같은 컴파일 툴체인이 이미지 안으로 다 들어옵니다. 로컬(macOS ARM64)에서는 멀쩡히 돌던 코드가 배포 서버(Linux x86_64)에 올라가자마자 메모리 세그멘테이션 오류(SIGSEGV)를 내며 죽는 일도 잦습니다.

Bun은 JPEG, PNG, WebP 코덱을 런타임 바이너리 안에 직접 포함하고 있습니다. 외부 컴파일러나 libvips 같은 OS 패키지를 따로 설치할 필요가 없습니다.

비교 항목 Node.js (Sharp + libvips) Bun 네이티브 (Bun.Image)
C++ 바인딩 의존성 node-gyp, N-API 필수 없음 (런타임 바이너리 내장)
빌드 툴체인 GCC, Python, make 필요 불필요
배포 패키지 용량 툴체인 포함 수백 MB 베이스 이미지 수준으로 축소
런타임 충돌 glibc/musl 불일치 시 SIGSEGV 발생 내장 정적 링크로 방지

Sharp 코드를 Bun.Image로 1대1 교체하기

Bun.Image는 체이닝 인터페이스를 지원하므로 기존 sharp 코드를 거의 그대로 옮겨올 수 있습니다. 메모리 복사 없이 Uint8Array를 바로 다룹니다.

// 기존 sharp 기반 코드
import sharp from "sharp";

export async function processImageSharp(inputBuffer: Buffer): Promise<Buffer> {
  const image = sharp(inputBuffer);
  const metadata = await image.metadata();
  
  if (!metadata.width || metadata.width > 2000) {
    return await image
      .resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
      .rotate(90)
      .webp({ quality: 85 })
      .toBuffer();
  }
  return inputBuffer;
}
// Bun.Image로 변환한 코드
export async function processImageBun(inputBytes: Uint8Array): Promise<Uint8Array> {
  // 전체 비트맵을 풀지 않고 헤더만 빠르게 읽어 크기 확인
  const meta = await new Bun.Image(inputBytes).metadata();
  
  if (!meta.width || meta.width > 2000) {
    return await new Bun.Image(inputBytes)
      .resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
      .rotate(90)
      .webp({ quality: 85 })
      .bytes();
  }
  return inputBytes;
}

metadata() 메서드는 이미지 전체를 디코딩하지 않고 헤더 영역만 파싱합니다. 큰 원본 이미지를 다룰 때 CPU 낭비를 줄여줍니다.

작업별 API 대응 방식은 다음과 같습니다.

  • 객체 생성은 sharp(buf) 대신 new Bun.Image(bytes) 또는 Bun.file(path).image()를 사용합니다.
  • 포맷 변환 및 압축은 .webp({ quality: 85 }) 체이닝을 그대로 유지합니다.
  • 최종 바이너리 추출은 .toBuffer() 대신 .bytes()로 호출해 Uint8Array를 반환받습니다.
  • UI 로딩용 블러 이미지는 별도 라이브러리 없이 .placeholder() 내장 메서드로 생성합니다.

마이그레이션 순서는 간단합니다. package.json에서 sharp와 @types/sharp를 지운 뒤 유틸리티 함수들의 출력 형식을 bytes()로 바꾸고, bun test로 기능 검증을 돌리면 됩니다.

Bun 내장 SQLite로 썸네일 캐시 구축하기

Cloudinary 같은 이미지 SaaS는 트래픽이 조금만 튀어도 청구 금액이 가파르게 올라갑니다. 1인 개발 서비스 단계에서는 bun:sqlite와 Bun.serve 조합만으로도 훌륭한 자체 리사이징 캐시 서버를 만들 수 있습니다.

import { Database } from "bun:sqlite";

const db = new Database("image_cache.sqlite");
// 동시 읽기 쓰기 성능을 위해 WAL 모드 적용
db.exec("PRAGMA journal_mode = WAL;");
db.exec(`
  CREATE TABLE IF NOT EXISTS image_cache (
    key TEXT PRIMARY KEY,
    data BLOB NOT NULL,
    placeholder TEXT NOT NULL,
    mime_type TEXT NOT NULL,
    created_at INTEGER NOT NULL
  )
`);

const selectQuery = db.query("SELECT data, mime_type FROM image_cache WHERE key = ?");
const insertQuery = db.query(`
  INSERT OR REPLACE INTO image_cache (key, data, placeholder, mime_type, created_at)
  VALUES (?, ?, ?, ?, ?)
`);

export async function getOrGenerateThumbnail(
  originalBytes: Uint8Array,
  cacheKey: string,
  width: number = 300
): Promise<{ bytes: Uint8Array; mimeType: string }> {
  const cached = selectQuery.get(cacheKey) as { data: Uint8Array; mime_type: string } | null;
  if (cached) {
    return { bytes: cached.data, mimeType: cached.mime_type };
  }

  const imagePipeline = new Bun.Image(originalBytes);
  const transformedBytes = await imagePipeline.resize(width).webp({ quality: 80 }).bytes();
  const placeholder = await imagePipeline.placeholder();

  insertQuery.run(cacheKey, transformedBytes, placeholder, "image/webp", Date.now());

  return { bytes: transformedBytes, mimeType: "image/webp" };
}
// 미디어 서빙 엔드포인트
Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    
    if (url.pathname.startsWith("/images/")) {
      const imageId = url.pathname.replace("/images/", "");
      const width = parseInt(url.searchParams.get("w") || "300", 10);
      const cacheKey = `${imageId}_w${width}`;

      const originalFile = Bun.file(`./uploads/${imageId}`);
      if (!(await originalFile.exists())) {
        return new Response("Image Not Found", { status: 404 });
      }

      const originalBytes = await originalFile.bytes();
      const { bytes, mimeType } = await getOrGenerateThumbnail(originalBytes, cacheKey, width);

      return new Response(bytes, {
        headers: {
          "Content-Type": mimeType,
          "Cache-Control": "public, max-age=31536000, immutable",
        },
      });
    }

    return new Response("Not Found", { status: 404 });
  },
});

처음 들어온 요청만 리사이징을 거쳐 SQLite에 BLOB 형태로 저장되고, 이후 요청은 DB 캐시에서 바로 나갑니다. 응답 헤더에 Cache-Control을 길게 잡아두면 브라우저와 CDN 레벨에서도 캐싱이 작동합니다.

워커 스레드로 메인 루프 블로킹 막기

이미지 인코딩과 디코딩은 CPU를 많이 쓰는 작업입니다. 업로드 요청이 몰릴 때 메인 이벤트 루프에서 이미지를 변환하면 전체 서버 응답이 멈춥니다. P99 지연 시간이 수백 밀리초 단위로 튀면서 API 서버 전체가 먹통이 되는 이유입니다.

Bun의 Worker API를 활용해 이미지 가공 작업을 백그라운드 스레드로 넘겨야 메인 루프가 살아남습니다.

// imageWorker.ts
declare var self: Worker;

interface ResizeTask {
  id: string;
  buffer: ArrayBuffer;
  width: number;
}

self.onmessage = async (event: MessageEvent<ResizeTask>) => {
  const { id, buffer, width } = event.data;

  try {
    const inputBytes = new Uint8Array(buffer);
    const processedBytes = await new Bun.Image(inputBytes)
      .resize(width)
      .webp({ quality: 80 })
      .bytes();

    self.postMessage(
      { id, success: true, buffer: processedBytes.buffer },
      [processedBytes.buffer] as any
    );
  } catch (error) {
    self.postMessage({ id, success: false, error: (error as Error).message });
  }
};
// server.ts
const worker = new Worker("./imageWorker.ts");
const pendingTasks = new Map<string, (buf: ArrayBuffer) => void>();

worker.onmessage = (event) => {
  const { id, success, buffer, error } = event.data;
  const resolve = pendingTasks.get(id);
  
  if (resolve && success) {
    resolve(buffer);
    pendingTasks.delete(id);
  } else if (!success) {
    console.error(`작업 실패 (${id}):`, error);
    pendingTasks.delete(id);
  }
};

export function dispatchImageJob(id: string, buffer: ArrayBuffer, width: number): Promise<ArrayBuffer> {
  return new Promise((resolve) => {
    pendingTasks.set(id, resolve);
    // Transferable 객체로 메모리 복사 없이 소유권만 이전
    worker.postMessage({ id, buffer, width }, [buffer]);
  });
}

Transferable ArrayBuffer를 쓰면 스레드 사이에 수 메가바이트짜리 버퍼를 주고받을 때도 메모리 복사 비용이 들지 않습니다. 대용량 요청이 밀려와도 메인 스레드는 202 Accepted 응답을 던지며 다른 API 요청을 지연 없이 처리합니다.

배포 전 확인해야 할 운영 주의사항

로컬 개발 환경과 배포 컨테이너 환경 사이에서 생길 수 있는 몇 가지 차이점을 미리 점검해야 합니다.

  • 코덱 지원 범위 확인: Bun.Image는 JPEG, PNG, WebP, GIF, BMP를 Linux, macOS, Windows 전 환경에서 공통 지원합니다. 반면 HEIC나 AVIF는 서버 OS에 따라 지원 여부가 갈릴 수 있습니다. Linux 프로덕션 환경에서는 기본 출력 형식을 WebP나 JPEG로 고정하는 것이 안전합니다.
  • 도커 파일 정리: Dockerfile에서 python3, make, g++, libvips-dev 같은 컴파일용 패키지 설치 구문을 완전히 제거합니다. 빌드 속도가 빨라지고 컨테이너 이미지도 훨씬 가벼워집니다.
  • 임시 디렉토리 권한: 보안을 위해 컨테이너 파일 시스템을 읽기 전용(Read-Only)으로 잠그는 경우, Bun 내부 디코딩 버퍼가 사용하는 /tmp 디렉토리에는 반드시 쓰기 권한이 열려 있어야 프로세스가 튕기지 않습니다.
  • SQLite WAL 모드 활성화: SQLite 파일 생성 직후 PRAGMA journal_mode = WAL;을 실행하지 않으면 동시 읽기 쓰기 요청이 몰릴 때 DB 락 에러를 만나게 됩니다.

불필요한 외부 의존성을 덜어내면 빌드가 깨질 확률 자체가 줄어듭니다. 단일 런타임의 내장 도구를 적극적으로 활용하는 것만으로도 서비스 운영 복잡도와 인프라 유지 비용을 꽤 많이 덜어낼 수 있습니다.