Bun Image로 Sharp를 대체하는 4단계 마이그레이션 가이드

Bun 1.3.14에 추가된 Bun Image로 Sharp 기반 이미지 파이프라인을 옮기는 4단계 실전 가이드입니다. CI 설치 시간, Docker 용량, 성능 비교와 ICC·애니메이션 WebP·tile 제한까지 한 번에 정리합니다. 도입 여부를 판단할 체크리스트도 담았습니다.

Saturday, September 5, 2026Omid Saffari
Bun Image로 Sharp를 대체하는 4단계 마이그레이션 가이드

Bun은 2026년 5월 13일 v1.3.14와 함께 Bun Image를 공개했습니다. Sharp API와 닮은 이 이미지 파이프라인은 libjpeg-turbo + spng + libwebp 조합으로 동작하며, 네이티브 애드온을 빌드하는 과정이 단 한 번도 필요 없습니다. Node 버전을 올릴 때마다 lovell/sharp의 libvips 바이너리 때문에 CI가 깨지는 일을 3년 겪고 나니, 저는 이 버전에서 마침내 Sharp를 걷어냈습니다.

Bun 1.3.14에 Bun Image가 나오자 Sharp를 걷어낸 이유

Bun 1.3.14 릴리스 노트는 5월 13일 여느 때처럼 항목별 목록으로 공개됐습니다. 그중 "HTTP/3 client"와 "7x faster warm installs" 사이에 제 Sharp 사용을 끝낸 한 줄이 숨어 있었습니다. 바로 런타임에 내장된 체이닝 방식의 이미지 파이프라인 Bun.Image입니다. libjpeg-turbo, spng, libwebp가 Bun 바이너리에 직접 컴파일되어 들어갑니다.

Sharp 때문에 CI 하루를 날려본 적이 없다면 이 문단은 건너뛰어도 됩니다. 한 번이라도 겪었다면 익숙한 장면일 겁니다. sharp/lib/sharp-linuxmusl-x64.node를 찾지 못하고, Alpine 컨테이너에서는 Cannot find module '../build/Release/sharp.node' 오류가 납니다. 누군가 Node 버전을 올리면 Docker 레이어 캐시가 무효화되고, 푸시할 때마다 npm rebuild sharp가 처음부터 다시 실행됩니다. Vercel 빌드는 사전 빌드 바이너리 CDN에 접속하다 타임아웃됩니다. 3개 프로젝트에서 3년 동안 이 일을 겪다 보니, 어느 순간부터는 apk add --no-cache vips-dev를 손이 먼저 기억해 입력하고 있었습니다.

Bun.Image는 이 문제를 런타임 차원에서 해결합니다. 이미지 처리용 npm install 단계가 따로 없습니다. 코덱이 Bun 바이너리 안에 들어 있기 때문입니다. Node ABI가 바뀔 때 다시 빌드할 네이티브 애드온도 없습니다. 애초에 Node도, 애드온도 쓰지 않습니다. 지오메트리 커널은 i16 고정소수점 SIMD로 구현됐고, JPEG 디코딩 단계에서는 필요한 최소 크기에 맞춰 자동으로 스케일링합니다. 구조만 놓고 보면 Sharp가 Node 애드온이어야 한다는 제약이 없었을 때의 모습에 가깝습니다.

1.3.14가 제게 중요했던 또 다른 이유도 있습니다. Anthropic의 지원을 받는 Rust 재작성 버전이 합쳐지기 전 마지막 Zig 릴리스이기 때문입니다. The Register는 5월 14일 병합 속도를 다뤘고, 앞으로 개발 속도는 상당히 빨라질 전망입니다. 런타임 재작성 과정 내내 네이티브 애드온 의존성을 끌고 가기보다는 지금 Bun 기본 기능으로 옮기는 편을 택했습니다.

그렇다고 Sharp의 사망 선언을 하려는 것은 아닙니다. libvips 기반 Sharp는 애니메이션 WebP, 색상 프로파일이 중요한 사진 작업, tile() 딥줌 피라미드에서 여전히 가장 빠릅니다. 아래에서는 이 세 가지를 제외한 이미지 작업 95%, 즉 대부분의 프로덕션 앱이 수행하는 디코딩-리사이즈-인코딩 파이프라인을 옮기는 방법을 다룹니다.

커버 이미지 파이프라인을 옮긴 4단계

저는 gpt-image-2가 만든 커버 이미지를 R2에 올리기 전에 후처리하는 워커 omidsaffari-admin에 이 전환을 적용했습니다. PublishWorkflow의 cover 단계 다음 경로에 있는 한 파일에서 Sharp 호출 지점 8곳을 바꿨습니다. WebP와 JPEG 폴백을 함께 인코딩하는 경로까지 포함해 전체 마이그레이션에는 42분이 걸렸습니다.

1단계 – Sharp를 어디까지 쓰고 있는지 점검합니다. 코드를 고치기 전에 모든 import부터 찾습니다.

Bash
rg -n "from ['\"]sharp['\"]" src/
rg -n "require\(['\"]sharp['\"]\)" src/

호출 지점이 4곳인지 40곳인지 먼저 파악해야 합니다. 40곳이라면 한꺼번에 바꾸지 말고 라우트별로 진행합니다.

2단계 – import를 Bun.file().image()로 바꿉니다. Sharp 생성자는 경로, Buffer, Stream을 받습니다. Bun.Image 생성자는 Bun.file()을 통한 경로, Uint8Array, Blob 또는 Bun 파일 기본 기능이 반환하는 모든 값을 받습니다. 여기에는 제 코드 구조를 바꿔놓은 Bun.s3() 참조도 포함됩니다.

3단계 – 메서드 체인을 대응시킵니다. Bun.Image가 "Sharp 호환"이라는 설명에 값하는 부분입니다. 제가 프로덕션에서 쓰던 메서드는 모두 1:1로 대응했습니다. .resize(w, h, { fit: "cover" })는 동일하고, .rotate(90)도 동작합니다(회전 관련 제한은 아래에서 설명합니다). .flip().flop(), .modulate({ brightness, saturation })도 같습니다. 포맷을 결정하는 마지막 메서드인 .webp({ quality }), .jpeg({ quality }), .png(), .avif(), .heic()도 모두 제공됩니다.

4단계 – 터미널 메서드를 교체합니다. Sharp의 .toBuffer()는 Bun.Image에서도 .toBuffer()입니다. 단, Buffer가 아닌 Uint8Array를 반환하므로 넘겨받는 코드가 Buffer 타입을 검사한다면 주의해야 합니다. Sharp의 .toFile(path).write(path)로 바뀝니다. 지연 실행 방식은 같습니다. 터미널 메서드를 await하기 전에는 아무 작업도 실행되지 않습니다.

다음은 실제 라우트 핸들러에서 가져온 diff입니다.

TypeScript
// before
import sharp from "sharp";

export async function processCover(input: Uint8Array) {
  const buf = await sharp(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
  return buf;
}

// after
export async function processCover(input: Uint8Array) {
  const buf = await Bun.image(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
  return buf;
}

일반적인 사용 사례라면 바꿀 것은 이것뿐입니다. import 한 줄과 생성자 호출 하나입니다.

마이그레이션 후 bun pm ls | grep sharp는 아무 결과도 내지 않습니다. CI Dockerfile에서는 RUN apk add --no-cache vips-dev 줄이 사라졌고, 최종 이미지는 약 80MB 작아졌습니다. package.json에서도 의존성 하나와 peer dependency 경고 하나가 없어졌습니다.

아직 깔끔하게 대응하지 않는 3가지

Sharp를 걷어내기 전에 아래 제약을 냉정하게 확인해야 합니다. 파이프라인이 리사이즈와 인코딩만 하는 구조가 아니라면 적어도 하나는 문제가 될 수 있습니다.

주의 1 – ICC 색상 프로파일 전달. Sharp의 .withMetadata({ icc: "p3" })는 인코딩 후에도 입력 색상 프로파일을 보존합니다. 1.3.14 기준 Bun.Image는 ICC를 제거합니다. 대부분의 웹 이미지처럼 sRGB 입력을 sRGB로 출력하는 작업에서는 차이가 보이지 않습니다. 하지만 사용자가 광색역 Display-P3 이미지를 올리고 그 프로파일이 유지되기를 기대하는 사진 파이프라인이라면 여전히 Sharp가 낫습니다. 반드시 Bun.Image를 써야 한다면 exifr로 ICC 청크를 읽고 인코딩한 뒤 수동으로 다시 붙일 수 있지만, 깔끔한 방법은 아닙니다.

주의 2 – 애니메이션 WebP와 GIF 프레임. Bun.Image는 애니메이션 입력의 첫 프레임만 디코딩하고 나머지는 버립니다. Sharp의 { animated: true }와 프레임별 접근에 대응하는 기능이 없습니다. 스프라이트 시트 처리, 애니메이션 썸네일 생성, 프레임 순회 작업을 한다면 여기서 막힙니다. 해당 코드 경로에는 Sharp를 남겨야 합니다.

주의 3 – .tile() 피라미드. Sharp는 libvips의 딥줌/IIIF 타일 생성 기능을 물려받았습니다. Leaflet 방식의 이미지 서버나 지도 타일 파이프라인, 박물관 수준의 확대 UI를 제공한다면 반드시 필요한 기능입니다. Bun.Image에는 타일 기본 기능이 없고, 당분간 추가되지 않을 가능성이 큽니다. 수십 년간 기능이 쌓인 libvips와 달리 Bun 팀은 먼저 일반적인 사용 사례에 집중할 것이기 때문입니다.

덧붙여 알아둘 작은 제약: Sharp의 .rotate(45)는 이중선형 보간으로 임의 각도 회전을 처리합니다. Bun.Image의 .rotate()는 90, 180, 270만 받습니다. 커버 이미지와 상품 썸네일 작업의 99%에는 문제가 되지 않지만, 기울기 보정이나 미적인 틸트 효과가 필요하다면 도입을 막는 제약입니다.

저는 위 조건에 걸리는 작업을 위해 듀얼 스택 패턴을 사용하고 있습니다. 일반 경로는 Bun.Image로 처리하고, 문제가 되는 경우에만 워커 스레드에 고정한 Sharp를 호출합니다.

TypeScript
async function process(input: Uint8Array, meta: ImageMeta) {
  if (meta.hasICC || meta.isAnimated || meta.needsTile) {
    const sharp = (await import("sharp")).default;
    return sharp(input)
      .resize(1200, 630, { fit: "cover" })
      .webp({ quality: 82 })
      .toBuffer();
  }
  return Bun.image(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
}

동적 import를 사용하면 느린 경로를 전혀 타지 않는 배포 대상의 번들에 Sharp가 포함되지 않습니다.

CI 설치 시간과 콜드 스타트 측정값

CI 사용 시간은 계속 누적되므로, 제가 가장 중요하게 본 결과는 설치 시간 차이였습니다.

Ubuntu x86_64 CI 러너에서 Sharp를 고정했을 때 웜 캐시 기준 bun install은 4.8s가 걸렸습니다. package.json에서 Sharp를 제거한 뒤에는 1.4s였습니다. Sharp 사전 빌드 바이너리 다운로드와 선택적 libvips 시스템 의존성 검사를 건너뛴 결과입니다.

콜드 설치(~/.bun/install/cachenode_modules가 없는 상태)는 18.2s에서 7.1s로 줄었습니다. 200개 패키지로 이루어진 트리에서 네이티브 애드온 하나를 뺐다고 설치 시간이 보통 이만큼 달라지지는 않습니다. Sharp의 postinstall이 트리에서 가장 느린 단일 단계였기 때문에 감소 폭이 컸습니다.

Bun 1.3.14에는 격리형 링커의 글로벌 스토어도 포함됐습니다. 릴리스 노트는 프로젝트 전체에서 이를 "7x faster warm installs"라고 설명합니다. Sharp 제거 효과까지 더하자 관리자 저장소의 전체 bun install 웜 사이클은 6.4s에서 1.1s로 줄었습니다. 로컬 개발 중 바로 체감되는 변화입니다. bun add some-package가 더는 커피를 마시고 올 만큼 오래 걸리지 않습니다.

Sharp 사전 빌드 바이너리와, 사전 빌드 폴백에 필요했던 Alpine 패키지 vips-dev를 제거하자 Docker 이미지는 약 80MB 작아졌습니다. 설치 단계 아래의 레이어가 의존성 변경에도 더 안정적으로 유지되어 CI 레이어 캐시 적중률도 높아졌습니다. Sharp 사전 빌드 바이너리 다운로드는 캐시를 자주 무효화하던 원인 중 하나였습니다.

저는 평소에도 개발 툴체인을 간결하게 관리합니다. 같은 성향으로 Claude Code 2.1.141 hooks가 출시된 당일 바로 적용했습니다. 이런 작은 개선이 누적될 때 1인 개발 조직도 경쟁력을 얻습니다. bun install이 5초 빨라진다는 말만 들으면 별것 아닌 듯하지만, 일주일에 80번 커밋한다면 이야기가 달라집니다.

가장 걱정했던 수치는 이미지 한 장당 지연 시간이었습니다. 대표 작업인 1024×1024 PNG를 품질 82의 512×512 WebP로 바꾸는 테스트에서 로컬 M2 기준 Sharp 0.34.2와 Bun.Image의 차이는 8% 이내였습니다. 웹 워크로드에서는 어느 쪽의 차이도 중요하지 않은 수준입니다.

출시된 지 18개월인 Bun.Image가 20년 역사의 libvips와 리사이즈 자체 성능에서 경쟁할 수 있는 이유는 구조에 있습니다. i16 고정소수점 SIMD 리사이즈 커널과, 디코딩 중 필요한 최소 크기로 줄이는 JPEG IDCT 스케일링을 함께 사용합니다. 4000×4000 JPEG를 전체 비트맵으로 디코딩한 뒤 리사이즈하는 대신, Bun.Image가 목표 해상도로 바로 디코딩합니다. Sharp도 libjpeg-turbo로 같은 최적화를 수행하므로 두 파이프라인의 결과가 비슷합니다.

메모리에서는 Bun.Image의 우위가 더 뚜렷합니다. 복사 없이 ArrayBuffer를 빌려 쓰는 방식 덕분에 50개 이상의 이미지를 일괄 처리할 때 최대 RSS가 Sharp보다 낮습니다. 한 번의 워커 호출에서 갤러리 전체를 처리한다면 의미 있는 차이지만, 요청마다 이미지 한 장만 처리한다면 체감하기 어렵습니다.

Sharp가 여전히 나은 경우와 Bun Image로 옮길 시점

다음 중 하나라도 해당한다면 Sharp를 유지합니다.

  • 애니메이션 WebP의 프레임에 하나씩 접근해야 합니다.
  • 광색역 사진 작업에서 ICC 색상 프로파일을 보존해야 합니다.
  • 이미지 서버나 지도 타일 작업에 .tile() 딥줌 피라미드를 사용합니다.
  • 보간을 적용한 임의 각도 회전이 필요합니다.
  • Bun을 사용할 수 없는 Node 전용 배포 대상에서 실행합니다. 예를 들면 Vercel Node functions, AWS Lambda의 Node runtime, Cloudflare Workers(아직 Bun 미지원)입니다.

다음 조건을 모두 충족한다면 Bun.Image로 옮깁니다.

  • 서비스 계층 중 적어도 한 곳에서 이미 Bun 런타임을 사용합니다.
  • 이미지 작업이 JPEG, PNG, WebP, AVIF, HEIC의 "디코딩, 리사이즈, 재인코딩"으로 구성됩니다.
  • CI에서 Sharp 사전 빌드 바이너리를 반복해서 다시 빌드하는 데 지쳤거나, Alpine 컨테이너를 배포하다 libvips 시스템 의존성 문제를 겪었습니다.

2026년 5월을 기준으로 솔직하게 평가하면 Bun.Image는 설치 용량이 훨씬 작고 네이티브 애드온 설정이 전혀 필요 없는, "일반적인 Sharp 작업의 95%" 수준입니다. 남은 5%에서는 Sharp가 쌓아온 libvips의 깊이가 여전히 강점입니다. 따라서 첫날부터 모든 곳에서 Sharp를 제거하지 말고 듀얼 스택 기간을 계획해야 합니다. Bun.Image에 잘 맞는 라우트부터 옮기고 그렇지 않은 라우트에는 Sharp를 유지한 뒤, 임의 각도 회전과 애니메이션 프레임이 추가될 가능성이 있는 Bun 1.4.x에서 다시 판단하는 편이 좋습니다.

업그레이드 시 지켜볼 기능은 임의 각도 회전, 애니메이션 WebP 프레임 접근, ICC 전달입니다. Rust 재작성 이후의 개발 속도를 고려하면 이 세 가지가 다음으로 추가될 가능성이 가장 큽니다. Bun 변경 로그를 구독하고 마이너 버전이 나올 때마다 듀얼 스택의 역할 분담을 다시 점검합니다.

실제로 배포할 Sharp 마이그레이션 커밋

다음은 전환 후 에러 처리와 engines 설정 고정까지 포함한 프로덕션 라우트입니다.

TypeScript
// package.json
// "engines": { "bun": ">=1.3.14" }

import { Hono } from "hono";

const app = new Hono();

app.post("/api/uploads", async (c) => {
  const form = await c.req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return c.json({ error: "no file" }, 400);
  }

  const input = new Uint8Array(await file.arrayBuffer());

  try {
    const webp = await Bun.image(input)
      .resize(1200, 630, { fit: "cover" })
      .webp({ quality: 82 })
      .toBuffer();

    const jpeg = await Bun.image(input)
      .resize(1200, 630, { fit: "cover" })
      .jpeg({ quality: 84 })
      .toBuffer();

    await Bun.s3().write(`covers/${crypto.randomUUID()}.webp`, webp);
    await Bun.s3().write(`covers/${crypto.randomUUID()}.jpg`, jpeg);

    return c.json({ ok: true });
  } catch (err) {
    return c.json({ error: String(err) }, 500);
  }
});

export default app;

다음 세 가지는 명시적으로 고정해둘 가치가 있습니다.

package.json"engines": { "bun": ">=1.3.14" } 줄은 반드시 필요합니다. Bun.Image는 1.3.14에 추가됐습니다. 이전 버전에서는 런타임에 Bun.image is not a function이 발생합니다. 프로덕션의 500 에러가 아니라 설치 에러로 드러나게 만들어야 합니다.

@types/bun을 대체한 bun-types 패키지는 1.3.14부터 Bun.Image 타입을 제공합니다. @ts-expect-error 임시 처방 없이도 tsc --noEmit이 통과합니다. 에디터에서 여전히 Bun.image에 빨간 밑줄이 생긴다면 bun-types 버전이 너무 낮게 고정된 것입니다.

롤백을 위해 한 번의 릴리스 주기 동안 sharpoptionalDependencies에 남기고, 앞서 제약 사항에서 사용한 동적 import 듀얼 스택을 폴백 경로로 둡니다. 프로덕션 지표가 일주일 동안 정상이라면 optionalDependencies에서 sharp를 제거하고 폴백 분기를 삭제합니다. 두 작업을 한 커밋에서 함께 처리하지 마십시오. 특히 신중하게 운영한다면 같은 주에 진행하지도 않는 편이 좋습니다.

Bun 기능이 프로덕션에 투입할 준비가 됐는지는 변경 로그의 문구로 판단할 수 없습니다. 직접 자신의 커밋에 넣을 수 있는지가 기준입니다. 이 기능이라면 배포하겠습니다.

Bun.Image를 Bun 외부(Node.js)에서도 사용할 수 있나요?

불가능합니다. Bun.Image는 npm 패키지가 아니라 런타임 내장 기능입니다. Node와 Bun에서 모두 쓸 수 있는 Sharp 대안이 필요하다면 bun-image-turbo 같은 서드파티 패키지를 검토하거나 Sharp를 계속 사용해야 합니다.

Sharp를 그대로 대체할 수 있는 API인가요, 아니면 형태만 참고했나요?

체인 구조와 메서드 이름은 의도적으로 Sharp와 호환됩니다. 생성자 → .resize / .rotate / .flip / .modulate.webp / .jpeg / .png / .avif 터미널 순서입니다. 대부분의 호출 지점은 import 줄만 바꾸면 됩니다. 다른 점은 임의 각도 회전, 애니메이션 프레임, ICC 전달, .tile()까지 네 가지입니다.

Bun.Image는 내부적으로 무엇을 사용하나요?

JPEG 디코딩과 인코딩에는 libjpeg-turbo, PNG에는 spng, WebP와 AVIF에는 libwebp, 지오메트리 처리에는 Bun 자체 SIMD 커널(i16 고정소수점 리사이즈)을 사용합니다. 모두 Bun 바이너리에 컴파일되어 있어 네이티브 애드온이나 재빌드 단계가 없습니다.

리사이즈 성능에서 Bun.Image와 Sharp의 차이는 어느 정도인가요?

일반적인 JPEG/PNG 리사이즈·재인코딩에서는 로컬 하드웨어 기준 두 도구의 차이가 약 8% 이내였습니다. 매우 큰 이미지를 스트리밍하거나 애니메이션 작업을 처리할 때는 Sharp의 libvips가 여전히 더 빠릅니다. Bun.Image의 더 큰 이점은 단일 리사이즈의 CPU 성능보다 설치 시간과 메모리에 있습니다.

지금 바로 마이그레이션해야 하나요?

Bun 런타임을 사용하고 이미지 파이프라인이 JPEG/PNG/WebP/AVIF의 디코딩·리사이즈·재인코딩이라면 그렇습니다. 애니메이션 WebP 프레임, ICC 색상 프로파일 보존, .tile() 딥줌에 의존한다면 해당 경로에는 Sharp를 유지하고 듀얼 스택으로 운영해야 합니다.

마지막 업데이트

2026년 9월 5일

카테고리Build

Google에서 이 사이트를 우선하기

Google 검색에서 omidsaffari.com을 선호 소스로 추가

omidsaffari.com을 선호 소스로 지정하면 Google이 Top Stories, AI Overviews, AI Mode에서 우선적으로 보여 줍니다.

Build의 다른 글

Build 글 전체 보기
뉴스레터

매주 일요일, 한 통의 편지. 뜨거운 의견이 아닌, 돌아가는 시스템.

AI 벤처 포트폴리오 운영에서 나오는 빌드 로그, 가동 중인 시스템, 현장 노트.

주간 발행. 스팸 없음. 언제든 해지 가능합니다.