mason
mason-log.

GitHub 로그인 중

sprite image 전처리.
mason

masonfe-hyunsu

이미지 스프라이트는 여러 개의 작은 이미지를 한 장으로 합친 뒤 CSS background-position으로 필요한 영역만 노출하는 방식이다. 개별 이미지 대신 하나의 시트와 좌표로 아이콘을 관리할 수 있다.

네이버에서 사용하는 여러 UI 아이콘을 한 장에 배치한 이미지 스프라이트

위 이미지는 네이버에서 사용한 스프라이트 시트다. 여러 UI 아이콘이 한 장에 배치되어 있고, 화면에서는 좌표로 필요한 영역만 잘라 쓴다.

아이콘이 늘어날수록 시트 안의 위치와 크기를 사람이 직접 기록하기는 어렵다. 원본 이미지를 합치고 각 좌표를 코드로 생성하는 단계가 필요한 이유다.

Spritesmith는 여러 이미지를 입력받아 하나의 스프라이트 이미지와 원본별 좌표, 전체 시트 크기를 반환하는 Node 패키지다. 이 결과를 이용하면 이미지 생성과 SCSS map 생성을 하나의 전처리 파이프라인으로 연결할 수 있다.

처음에는 Spritesmith 기반 생성 작업을 Webpack 빌드에 포함하려 했다. 하지만 플러그인이 사용하는 compiler hook과 프로젝트의 Webpack 환경이 맞지 않아 빌드 단계에서 호환 오류가 발생했다.

호환되는 조합을 다시 찾는 대신 생성 자체를 번들러에서 분리했다.

PNG 원본을 합치고 좌표를 계산하는 작업은 Node 스크립트가 맡고, Webpack은 생성된 WebP와 SCSS만 소비하도록 경계를 바꿨다.

핵심은 .mjs 확장자가 아니라, 스프라이트 생성 단계를 Webpack의 컴파일 생명주기 밖으로 옮긴 데 있다.

이 포스팅에서는 sprites.mjs를 기준으로 입력 이미지가 WebP 스프라이트와 SCSS map, mixin으로 바뀌는 과정을 정리해 본다.


🧩 1. Webpack 처리 배경과 호환 오류.

처음 Webpack에서 처리하려던 이유는 자동화였다. 아이콘 원본이 바뀌면 개발 서버와 프로덕션 빌드가 시작될 때 스프라이트 이미지와 좌표 SCSS도 함께 생성되도록 구성하려 했다. 별도 명령의 실행 누락과 원본, 산출물 사이의 불일치를 줄일 수 있다는 판단이었다.

문제는 Spritesmith가 이미지를 못 합쳐서가 아니었다. 이미지를 합치는 쪽은 괜찮았고, 그걸 Webpack 빌드 안에 붙이는 플러그인 경계에서 막혔다.

Webpack 플러그인은 apply에서 compiler를 받아 빌드 훅에 자기 작업을 걸어 둔다. Webpack Plugin API도 같은 구조다. 문제는 간단했다. 연동 코드가 기대하던 hook과 프로젝트 Webpack이 서로 안 맞았다. 스프라이트를 만들기 전에 빌드가 먼저 죽었다.

Spritesmith 자체는 Node에서 이미지를 합치고 좌표를 뽑는 데 문제 없었다. 막힌 곳은 그 결과를 Webpack 생명주기 안으로 집어넣는 연결부였다. 플러그인 버전을 고정하거나 hook을 프로젝트에 맞게 고칠 수는 있었다. 다만 Webpack을 올릴 때마다 같은 호환을 다시 봐야 한다.

  • 입력: 시트별 PNG 파일

  • 출력: WebP 스프라이트 이미지, 좌표를 가진 SCSS map

  • 소비자: SCSS를 처리하고 이미지를 번들링하는 기존 빌드

이번 생성 작업은 Webpack 내부 모듈 그래프나 compilation 상태를 사용하지 않는다. 입력과 출력이 파일로 명확했기 때문에 플러그인을 고치는 대신 생성 과정을 별도 Node 명령으로 분리했다. 이렇게 하면 Webpack 버전을 바꾸더라도 전처리 스크립트는 같은 방식으로 실행되고, 번들러에는 정적 산출물만 전달된다.


⚙️ 2. Node 전처리 파이프라인.

전체 흐름은 다음과 같이 단순하게 잡았다.

PNG 원본에서 WebP 스프라이트와 SCSS map을 생성해 화면에 적용하는 전처리 흐름

scripts/sprites.mjssprite-src 아래의 폴더를 각각 하나의 시트로 취급한다. 예를 들어 common 폴더에 아이콘을 넣으면 sprite-common.webp_sprite-common.scss가 생성된다.

origin/assets/images/
├── sprite-src/
│   ├── common/
│   │   ├── ico-arrow.png
│   │   └── ico-close.png
│   └── event/
│       └── ico-reward.png
└── sprite/
    ├── sprite-common.webp
    └── sprite-event.webp

시트를 폴더 단위로 나눈 이유는 모든 아이콘을 하나의 큰 이미지로 합치지 않기 위해서다. 화면 성격에 따라 시트를 분리하면 쓰지 않는 이미지까지 함께 내려받는 범위를 줄이고, 생성된 좌표도 관리하기 쉬워진다.


📦 3. .mjs 실행 경계.

스크립트는 ESM 문법으로 작성했다. Node 공식 문서에 따르면 .mjs 파일은 가장 가까운 package.json"type" 값과 관계없이 항상 ES Module로 처리된다. 따라서 애플리케이션 전체의 모듈 형식을 바꾸지 않고 전처리 스크립트만 ESM으로 고정할 수 있다.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import sharp from 'sharp';
import Spritesmith from 'spritesmith';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

const runSpritesmith = promisify(Spritesmith.run.bind(Spritesmith));

여기서 .mjs가 해결하는 것은 Node의 모듈 해석 방식이다. Webpack 호환성을 직접 해결하는 확장자는 아니다. Webpack과의 결합이 사라진 이유는 이 파일을 node scripts/sprites.mjs로 별도 실행하기 때문이다.

Spritesmith.run은 콜백 API를 제공하므로 promisify로 감싸 파이프라인을 async/await로 구성했다. Spritesmith 공식 저장소에 명시된 반환값 가운데 다음 세 값을 사용한다.

  • image: 생성된 PNG 이미지의 Buffer

  • coordinates: 원본 파일별 x, y, width, height

  • properties: 전체 스프라이트 시트의 width, height

const result = await runSpritesmith({
  src: pngFiles,
  padding: 2,
});

const { image, coordinates, properties } = result;

padding을 두어 인접한 아이콘의 경계가 스케일링 과정에서 섞일 가능성도 줄였다.


🖼️ 4. PNG에서 WebP로.

Spritesmith가 반환한 Buffer는 Sharp에 바로 전달할 수 있다. 병합 결과는 중간 PNG 저장 없이 WebP로 변환해 최종 경로에 기록했다.

const WEBP_OPTIONS = {
  quality: 90,
  effort: 6,
};

const writeSpriteWebp = async (pngBuffer, imageOutPath) => {
  await sharp(pngBuffer).webp(WEBP_OPTIONS).toFile(imageOutPath);
};

Sharp WebP 출력 문서quality를 1부터 100까지, effort를 0부터 6까지 지원한다고 설명한다. 이 작업에서는 런타임이 아니라 개발 전처리 단계에서 실행하므로 인코딩 시간보다 결과 크기에 무게를 두어 effort: 6을 사용했다.

다만 quality: 90이 모든 아이콘에 최적인 값은 아니다. 선명한 경계와 투명 영역이 중요한 에셋은 실제 화면과 파일 크기를 함께 비교해야 한다. 필요하다면 시트 성격에 따라 lossless WebP나 다른 품질 값을 선택할 수 있도록 옵션을 분리하는 편이 안전하다.


🗺️ 5. SCSS map 좌표 생성.

이미지만 합치면 각 아이콘의 위치를 직접 계산해야 한다. 전처리 단계에서 coordinatesSCSS map으로 바꾸고, 화면에서는 파일명만 전달하도록 만들었다.

$sprite-common: (
  'ico-arrow': (
    width: 24,
    height: 24,
    x: 0,
    y: 0,
  ),
  'ico-close': (
    width: 20,
    height: 20,
    x: -26,
    y: 0,
  ),
);

생성 시 x, y에 음수를 적용해 CSS의 background-position에 바로 사용할 수 있도록 했다. 공통 mixin은 map에서 좌표와 크기를 꺼내 background-size, background-position, 요소 크기를 함께 출력한다.

@mixin sprite($map, $image, $sheet-width, $sheet-height, $name, $scale: 1, $unit: px) {
  $sprite: map.get($map, $name);

  @if $sprite == null {
    @error 'Sprite "#{$name}" not found in map.';
  }

  display: inline-block;
  width: sprite-to-length(map.get($sprite, width) * $scale, $unit);
  height: sprite-to-length(map.get($sprite, height) * $scale, $unit);
  background-image: url($image);
  background-repeat: no-repeat;
  background-size:
    sprite-to-length($sheet-width * $scale, $unit)
    sprite-to-length($sheet-height * $scale, $unit);
  background-position:
    sprite-to-length(map.get($sprite, x) * $scale, $unit)
    sprite-to-length(map.get($sprite, y) * $scale, $unit);
}

화면에서는 생성된 시트별 mixin만 호출한다.

.closeButton {
  @include sprite-common('ico-close');
}

.smallArrow {
  @include sprite-common('ico-arrow', 0.5, rem);
}

$scale을 위치, 배경 크기, 요소 크기에 동일하게 적용해야 아이콘 영역과 배경 좌표가 어긋나지 않는다. px, rem 변환도 mixin 내부에 두어 호출부에서 좌표를 다시 계산하지 않게 했다.


🔄 6. 생성물 수명주기 관리.

전처리 스크립트는 생성만 잘해서 끝나지 않는다. 생성물 수명주기를 함께 관리하지 않으면 원본 폴더가 비거나 삭제돼도 이전 WebP와 SCSS가 남아 유효하지 않은 시트를 계속 참조할 수 있다.

그래서 시트에 PNG가 없을 때는 이전 산출물을 제거하고, 현재 생성된 시트만 모아 _sprite-sheets.scss의 import 목록을 다시 만든다.

const removeStaleSheetArtifacts = (sheetName) => {
  const stalePaths = [
    `_sprite-${sheetName}.scss`,
    `sprite-${sheetName}.webp`,
    `sprite-${sheetName}.png`,
  ];

  stalePaths.forEach(removeIfExists);
};

명령은 개발 서버와 프로덕션 빌드 전에 실행되도록 연결했다.

{
  "scripts": {
    "sprite": "node scripts/sprites.mjs",
    "prestart": "npm run sprite",
    "build": "npm run sprite && nuxt build"
  }
}

자동 실행은 누락을 막아 주지만 원본 수가 많아지면 매번 전체 시트를 다시 만드는 비용이 생긴다. 규모가 커지면 파일 해시를 비교해 변경된 시트만 생성하거나, CI에서 생성물 차이를 검사하는 방식까지 고려할 수 있다.


⚖️ 7. 버전 독립성의 범위.

이번 구조를 Webpack 버전에 종속되지 않는 방식이라고 표현할 수는 있지만, 모든 도구의 버전에서 자유롭다는 뜻은 아니다.

  • 분리된 부분: Webpack 빌드 설정과 결합할 때 생길 수 있는 플러그인 API, compiler hook 의존성

  • 남아 있는 의존성: Node 버전, Spritesmith와 Sharp API, Sass 문법

  • 고정된 계약: WebP 이미지 경로, 생성된 SCSS map과 mixin

Webpack은 결과 파일만 처리하므로 교체하거나 업그레이드할 때 스프라이트 생성 로직까지 함께 수정할 가능성이 줄었다. 반면 Node 전처리 도구의 호환성과 생성물 검증은 별도로 관리해야 한다.

.mjs는 Node가 전처리 스크립트를 ESM으로 해석하게 만드는 선택일 뿐이다. 실제 호환 범위를 넓힌 것은 변경 주기가 다른 스프라이트 생성 책임을 Webpack 빌드 경계 밖으로 분리한 구조였다.


✅ 8. 정리.

  • Webpack 빌드에 결합하려던 스프라이트 생성을 Node 전처리 명령으로 분리했다.

  • .mjs로 해당 스크립트의 ESM 해석을 명확히 고정했다.

  • Spritesmith의 이미지와 좌표 결과를 Sharp, SCSS 생성 단계로 연결했다.

  • 원본 삭제 시 이전 산출물도 제거해 생성물과 소스의 상태를 맞췄다.

  • Webpack 독립성과 전체 도구 체인의 버전 독립성을 구분했다.

번들러 호환 문제를 피한 지점은 특정 버전을 맞춘 것이 아니라, 번들러가 몰라도 되는 작업을 빌드 전처리로 꺼낸 데 있다.


🔗 9. 참고 자료.

이전 글
스프라이트 애니메이션 패키지, mason-sprite.
다음 글
다음 글이 없습니다.

댓글

불러오는 중...
목록으로