Skip to content

perf: 배포 용량 경량화 — sharp 제거로 50MB → 19MB - #7

Merged
NariP merged 3 commits into
mainfrom
perf/slim-distribution
Sep 4, 2026
Merged

NariP merged 3 commits into
mainfrom
perf/slim-distribution

Conversation

@NariP

@NariP NariP commented Sep 4, 2026

Copy link
Copy Markdown
Owner

무엇이 바뀌나

npx 로 설치되는 용량이 50MB → 19MB (-62%) 로 줄었다. 첫 실행 시 다운로드가 그만큼 빨라진다.

가장 큰 변화는 sharp 를 걷어내고 @jsquash wasm 코덱으로 교체한 것이다. 이미지 변환·렌더링·내보내기 동작은 동일하고, 출력 픽셀도 그대로다.

배경

Closes #6

플러그인 마켓플레이스 배포(#4)로 npx -y -p editable-pixel@latest editable-pixel-mcp 경로가 열리면서 첫 실행 다운로드 용량이 사용자 체감에 직접 영향을 주게 됐다. 50MB 내역을 실측한 결과 sharp 계열이 27MB 로 절반이었다.

당초 계획이었던 @img/sharp-wasm32 만 차단하는 방식은 불가능으로 판명났다. npm overrides 는 루트 프로젝트에서만 유효하고 하위 소비자로 전파되지 않는다 — tarball 로 패킹해 하위 설치하면 8.7MB 가 그대로 복귀하는 것을 실측으로 확인했다. 우리 package.json 에 넣어도 고친 것처럼 보이는 no-op 이 될 뿐이다. 그래서 sharp 자체를 제거하는 방향으로 전환했다 (이슈 코멘트에 상세 기록).

작업 내용

sharp → @jsquash 교체

  • packages/pixel-image-codec/ — 신규 워크스페이스 패키지. wasm 코덱 래핑(decodeImage/encodePng), 매직바이트 포맷 판별, resizeNearest, compositeRgba/blitRgba
  • packages/pixel-renderer/src/node.ts:40-105 — sharp 호출 6곳 교체
  • packages/pixel-converter/src/index.ts:166,229 — sharp 호출 6곳 교체, 기존 blitRgba 를 공용 유틸로 승격
  • packages/pixel-renderer/src/gif-metadata.ts, tests/support/gif.ts — sharp(gif,{animated:true}) 대체 GIF 리더
  • sharp 를 4개 package.json + pnpm-workspace.yaml 에서 제거

후보 조사는 전부 실제 설치·실행으로 검증했다. jimp 은 npm 메타데이터상 3.3MB 지만 실측 31MB 로 sharp 보다 크고 WebP 도 안 되며, @napi-rs/canvas 도 실측 26MB 로 이득이 없었다. JPEG/WebP 디코드를 만족하면서 유의미하게 작은 건 @jsquash(1.8MB) 뿐이었다.

배포 설정 정리

  • packages/pixel-cli/package.json:25 — files 의 "dist" → "dist/*.js". 소스맵 1.9MB 제외(개발 빌드는 유지)
  • packages/pixel-cli/scripts/prepare-package.mjs:23-38 — docs/media 616KB 제외. docs/*.md 는 유지
  • apps/web/index.html:8-12 — Pretendard 를 jsDelivr CDN 으로 전환(3.0MB 제외) + preconnect
  • apps/web/src/styles.css:6-7 — 시스템 UI 폰트 폴백 스택 보강

측정

before after
사용자 설치 (du -sh node_modules) 50MB 19MB (-62%)
npm pack unpacked 7.04MB 1.76MB
npm pack 파일 수 135 30
apps/web/dist 3.9MB 884KB

남는 19MB 중 @modelcontextprotocol 7.5MB + zod 6.3MB 는 손댈 수 없다. zod 는 MCP SDK 가 dependencies 로 하드 의존하고 내부에서 from "zod/v4" 로 직접 import 하므로, 우리가 걷어내도 SDK 가 동일 패키지를 끌어와 절감 0 이다.

자가체크

루프 — 4회차에서 APPROVE.

코드 리뷰 — 위반 2건 지적, 전부 수정:

  • tests/distribution/slim-package.test.ts 의 bin 검증이 자기가 만든 문자열을 자기가 검사하는 tautology 였다. Object.entries(manifest.bin) 순회로 교체하고 splitting: false 단언을 추가했다.
  • blitRgba 에 입력 길이 검증이 없어, 짧은 버퍼가 들어오면 subarray 가 짧은 슬라이스를 반환해 조용히 절반만 그려졌다. sharp 의 .composite() 는 예외를 던졌으므로 회귀였다. 진입부 검증 + RangeError 로 수정.

개선 3건(init 실패 영구 캐시, 디코드 에러 진단 정보 부재, prepare-package.mjs 의 부분문자열 매칭)도 반영했다.

테스트 — pnpm verify exit 0, 251 tests. pnpm test:e2e 23/23 통과.

mutation check 로 각 수정이 실제로 잡는지 확인했다:

변형 결과
manifest.bin → ./dist/sub/cli.js bin 테스트 실패
splitting: true 실패
짧은 input 가드 제거 2건 실패
ragged target 가드 제거 실패
디코드 진단 메시지 제거 실패
relative() → substring 환원 실패
once() → rejection 캐시 retry 테스트 실패

QA 감사 — pass. 골든값이 sharp 시절 바이트임을 구조로 확인했다(SHARP_PNG 에 pHYs 청크가 있는데 우리 인코더는 이를 emit 하지 않으므로, 자기 출력을 자기가 승인한 것이 아님). resize 골든에 축소 2건·비등방 1건이 포함돼 중심 샘플링 공식 오류를 잡을 수 있다는 점도 확인됐다.

제거한 테스트 — 없음. 기존 기대값도 수정하지 않았다.

폐기한 완료 기준 1건 — PR self-check: pnpm install 후 @img/sharp-wasm32 가 없고... 는 pnpm 이 부모의 os/cpu 를 존중해 main 에서도 이미 참이었다. 변경 전후가 동일한 기준은 아무것도 증명하지 못한다. package-install.mjs:92 의 assert.rejects(access(@img), ENOENT) 로 대체됐다.

QA 시나리오

이미지 파이프라인이 교체됐으므로 실제 변환·렌더링 경로를 확인해달라.

  1. PNG 변환 — editable-pixel convert <파일>.png --size 32 → Pixel Document 가 생성되고 색상이 원본과 맞는지
  2. JPEG/WebP 변환 — 같은 명령을 .jpg, .webp 로. 특히 WebP 는 이번에 디코더가 바뀐 포맷이다
  3. 렌더링 — editable-pixel render <파일>.pixel.json --scale 4 → 정수 배율에서 픽셀이 뭉개지지 않는지
  4. GIF 내보내기 — 에디터에서 여러 프레임을 만들고 GIF 로 내보내 프레임 간 잔상이 없는지. GIF disposal 처리가 이번에 변경된 지점이다
  5. 스프라이트시트 내보내기 — 타일이 올바른 위치에 배치되는지
  6. 잘못된 파일 — TIFF 나 깨진 파일을 넣었을 때 에러 메시지에 바이트 정보가 나오는지
  7. 에디터 한글 표시 — 브라우저에서 에디터를 열어 한글 UI 가 정상인지. 네트워크를 끊고 새로고침해 CDN 이 실패해도 시스템 폰트로 폴백되어 한글이 깨지지 않는지

실제 배포본으로 1·3번은 이미 확인했다 — pack → 격리 디렉터리 설치 → 실행으로 convert(PNG 디코드)와 render(32x32 PNG 출력) 정상 동작, sharp/@img 부재, wasm 6개 포함을 확인했다.

알려진 갭

  • 알파 divergence 가 테스트로 고정돼 있지 않다. sharp 는 resize 시 알파를 premultiply 해 반투명 픽셀의 RGB 를 바꾸지만 우리는 하지 않는다. 현재 파이프라인에서는 thresholdAlpha 가 resize 전에 알파를 0/255 로 이진화하므로 도달 불가능하고, 코드 리뷰와 QA 가 각각 코드 추적으로 이를 확인했다. 다만 불변조건이 주석으로만 존재해, 향후 리팩터링이 thresholding 을 뒤로 옮기면 조용히 깨진다. 후속 이슈로 테스트를 추가할 만하다.
  • docs/media 필터 테스트가 스크립트를 import 하지 않고 로직을 미러링한다. prepare-package.mjs 가 top-level 에서 복사를 실행해 import 시 빌드가 돌기 때문이다. 스크립트에 relative(repositoryRoot, source) 가 있는지 단언해 고정했고, package-install.mjs 가 실제 tar 목록으로 한 번 더 검증한다.
  • blitRgba 의 검증 추가는 공개 export 의 계약을 조인다. 외부 소비자가 있다면 동작 변경이다. 저장소 내 두 호출자는 모두 안전함을 확인했다.

리뷰 포인트

  • sharp 제거에 동의하는가 — 네이티브 대신 wasm 을 쓰는 트레이드오프 (성능 실측: 512² encode 15ms/decode 9ms, 4096² 191/235ms, 프로세스 시작 시간에 묻히는 수준)
  • Pretendard CDN 전환 — 오프라인에서 시스템 폰트 폴백을 받아들일 수 있는가
  • packages/pixel-image-codec/ 를 별도 패키지로 분리한 구조가 적절한가
  • 알려진 갭 3건을 후속 이슈로 뺄지

후속 이슈 후보

  • 알파 divergence 불변조건을 테스트로 고정
  • prepare-package.mjs 의 필터를 side-effect 없는 모듈로 분리해 테스트가 직접 import 하도록
  • @modelcontextprotocol 의 소스맵·미사용 서브패키지(ajv, cfWorker, shims) 업스트림 리포트

🤖 auto-generated

Cut the npx install from 50MB to 19MB. sharp's libvips binaries were
27MB of that, and the @img/sharp-wasm32 half of it was never loaded.
Blocking that one package is not possible from here: npm overrides
only apply at the root project, so they never reach an end user's
install. Removing sharp does.

@jsquash ships the PNG/JPEG/WebP codecs as wasm in 1.8MB. It has no
resize or composite, but the pipeline only ever asked sharp for
nearest-neighbour scaling and RGBA blitting, both of which already
existed here in pure JS. Decode output was compared against sharp
before removing it: PNG and WebP byte-identical, JPEG within the
mozjpeg/libjpeg-turbo DCT rounding margin, resize and GIF exact.

Also drop three things the archive never needed: sourcemaps (kept in
dev builds), docs/media screenshots, and the 92 bundled Pretendard
subsets, now loaded from a CDN behind a system-font fallback stack.
prepare-package.mjs copied web/, docs/ and skills/ with `force: true`,
which overwrites colliding files but never removes ones that are no
longer produced. Anything left in packages/pixel-cli/docs/ from an
earlier build therefore survived the docs/media filter entirely and was
picked up by `files: ["docs"]` at pack time — invisible to git, since
that path is gitignored. Windows CI caught it as a stray
package/docs/media/ in the tarball.

postpack --clean does run, but only after the tarball is sealed, so it
cannot prevent a leftover from shipping. Clean the destinations before
copying instead, which makes the build idempotent regardless of whether
postpack ever ran.

The new test plants leftovers in a fixture checkout and asserts the
build removes them, covering the output rather than the filter source
text that the existing test checks.
The docs filter compared `relative(repositoryRoot, source)` against
`docs/media`, which only holds while both sides spell the directory the
same way. They need not: `import.meta.url` is realpath-normalized by the
ESM loader, while the path handed to `fs.cp` is whatever the caller
wrote. Under a junction, a subst drive, an 8.3 short name, or a symlink,
`relative()` returns a `..`-prefixed path that matches no exclusion, and
the filter silently copies `docs/media` into the archive.

Canonicalize both sides with realpathSync.native, and reject anything
resolving outside the repository root rather than publishing it. The
same class of bug was fixed for the CLI entrypoint check in af48eae.

The win32 regression runs on every platform, since path.win32 makes the
comparison a pure function.
@NariP
NariP merged commit 0bffe52 into main Sep 4, 2026
2 checks passed
@NariP
NariP deleted the perf/slim-distribution branch September 4, 2026 10:42
@NariP NariP mentioned this pull request Sep 4, 2026
3 tasks
NariP added a commit that referenced this pull request Sep 4, 2026
* perf: replace sharp with wasm codecs and slim the published package

Cut the npx install from 50MB to 19MB. sharp's libvips binaries were
27MB of that, and the @img/sharp-wasm32 half of it was never loaded.
Blocking that one package is not possible from here: npm overrides
only apply at the root project, so they never reach an end user's
install. Removing sharp does.

@jsquash ships the PNG/JPEG/WebP codecs as wasm in 1.8MB. It has no
resize or composite, but the pipeline only ever asked sharp for
nearest-neighbour scaling and RGBA blitting, both of which already
existed here in pure JS. Decode output was compared against sharp
before removing it: PNG and WebP byte-identical, JPEG within the
mozjpeg/libjpeg-turbo DCT rounding margin, resize and GIF exact.

Also drop three things the archive never needed: sourcemaps (kept in
dev builds), docs/media screenshots, and the 92 bundled Pretendard
subsets, now loaded from a CDN behind a system-font fallback stack.

* fix: rebuild the package staging tree instead of merging into it

prepare-package.mjs copied web/, docs/ and skills/ with `force: true`,
which overwrites colliding files but never removes ones that are no
longer produced. Anything left in packages/pixel-cli/docs/ from an
earlier build therefore survived the docs/media filter entirely and was
picked up by `files: ["docs"]` at pack time — invisible to git, since
that path is gitignored. Windows CI caught it as a stray
package/docs/media/ in the tarball.

postpack --clean does run, but only after the tarball is sealed, so it
cannot prevent a leftover from shipping. Clean the destinations before
copying instead, which makes the build idempotent regardless of whether
postpack ever ran.

The new test plants leftovers in a fixture checkout and asserts the
build removes them, covering the output rather than the filter source
text that the existing test checks.

* fix: compare packaging paths by canonical form, and fail closed

The docs filter compared `relative(repositoryRoot, source)` against
`docs/media`, which only holds while both sides spell the directory the
same way. They need not: `import.meta.url` is realpath-normalized by the
ESM loader, while the path handed to `fs.cp` is whatever the caller
wrote. Under a junction, a subst drive, an 8.3 short name, or a symlink,
`relative()` returns a `..`-prefixed path that matches no exclusion, and
the filter silently copies `docs/media` into the archive.

Canonicalize both sides with realpathSync.native, and reject anything
resolving outside the repository root rather than publishing it. The
same class of bug was fixed for the CLI entrypoint check in 37cb61f.

The win32 regression runs on every platform, since path.win32 makes the
comparison a pure function.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant