jpgboost-cli로 CI 파이프라인에서 이미지 압축을 자동화하는 방법
압축되지 않은 JPEG와 PNG가 쌓여 가는 저장소는 LCP를 떨어뜨리는 동시에 클론할 때마다 더 무거워집니다. jpgboost-cli를 쓰면 그 압축 과정을 CI 파이프라인 안에서 직접 자동화할 수 있어, 기여자 한 사람 한 사람의 주의력에 의존하지 않아도 됩니다. 이 글에서는 GitLab CI와의 통합을 소개하고, 정반대되는 두 가지 전략을 비교한 뒤, 운영 환경에서 큰 대가를 치를 수 있는 함정들을 짚어봅니다.
압축하지 않은 이미지의 실제 비용
압축하지 않고 커밋한 이미지는 그 내용을 담은 blob으로 Git 히스토리에 흔적을 남깁니다. 나중에 그 이미지를 더 가벼운 버전으로 교체하더라도, 예전 blob은 히스토리에 그대로 남습니다. git clone은 그 히스토리까지 함께 받아옵니다. 그래서 오래된 이미지의 무게는 이미 쓰이지 않게 된 뒤에도 저장소를 계속 짓누릅니다.
스크린샷, 마케팅 소재, 디자인 내보내기 파일이 여러 달에 걸쳐 쌓이는 저장소라면, 이 죽은 무게는 금세 수백 메가바이트에 이릅니다. 이는 CI에서의 클론을 느리게 만들고 모든 기여자의 대역폭 소모를 늘립니다.
운영 환경 쪽에서도 문제는 저장소에서 끝나지 않습니다. 크기가 과도한 이미지는 Largest Contentful Paint(LCP)를 직접 악화시키며, 특히 첫 화면에서 가장 큰 이미지가 그 지표를 결정하는 요소가 될 때 더욱 그렇습니다. 압축으로 얻는 효과는 상당할 수 있습니다. jpgboost-cli 문서 자체의 예시에서는 4.2 MB짜리 JPEG가 890 KB까지 줄어, 79 % 감소했습니다.
수동 압축은 확장되지 않습니다. 개인의 습관에 기대기 때문입니다. 머지 리퀘스트마다 기여자 각자가 커밋 전에 이미지를 최적화하는 것을 잊지 말아야 합니다. 실제로는 시간이 촉박하거나 급한 상황이 되면 이 단계는 쉽게 건너뛰게 됩니다. 문제는 대개 몇 주 뒤, 감사나 성능 보고서가 누적된 부채를 드러낼 때에야 발견됩니다.
로컬이나 서버가 아니라 CI에서 자동화하는 이유
로컬에서 압축하는 방식, 예컨대 Git의 pre-commit 훅을 쓰는 방식은 언제나 각자의 머신과 기여자의 규율에 좌우됩니다. 훅은 우회할 수도, 제거할 수도 있고, 파일을 직접 커밋하는 디자이너의 머신에는 애초에 없을 수도 있습니다.
반면 서버 쪽 압축은 너무 늦게 개입합니다. 이미지는 이미 색인되었고, 이미 한 번쯤 제공되었을 수도 있습니다. 즉 문제는 초기 방문자에게 그대로 노출되며, 이후에도 배포할 때마다 최적화를 다시 해야 합니다.
CI는 가장 신뢰할 수 있는 검사 지점입니다. 머지 리퀘스트마다 실행되고, 로컬 머신의 설정에 좌우되지 않으며, 작업 로그에 눈으로 확인 가능하고 추적 가능한 결과를 남깁니다. 바로 이 유일한 관문이 있기에 자동화가 값어치를 합니다. 물론 처음부터 감안해야 할 인프라 비용은 따릅니다.
여기서는 그 비용이 더 무겁게 다가옵니다. jpgboost-cli는 패키지 관리자로 설치할 수 있는 독립 실행 파일이 아니기 때문입니다. JPGBoost.app과 함께 배포되며, macOS 15 이상에서만 동작하고, Pro 라이선스를 필요로 합니다.
따라서 이 글의 나머지 내용은 하나의 구체적인 전제 위에 세워집니다. 바로 상시 유지되는 자체 호스팅 macOS 러너입니다. 안정적인 환경을 확보하고, JPGBoost.app 설치 상태를 작업에서 작업으로 이어가기 위해서입니다.
jpgboost-cli 설치와 로컬 테스트
러너에서든 로컬 테스트에서든, 실행 파일은 앱 번들 안에 들어 있습니다. 심볼릭 링크를 한 번만 만들어 두면 이후에는 어느 폴더에서든 호출할 수 있습니다.
# 어느 폴더에서든 jpgboost-cli를 호출할 수 있도록 심볼릭 링크를 한 번만 생성
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli
# 명령이 응답하는지 확인
jpgboost-cli --help
그다음 Pro 라이선스를 러너에서 수동으로 한 번만 활성화합니다. 이 과정이 파이프라인 안에서 일어나는 일은 결코 없습니다. 이 라이선스를 활성화하는 첫 설치라면, 구매 후 이메일로 받은 토큰을 사용하세요.
# 이 머신의 식별자. 필요하면 지원팀에 전달
jpgboost-cli --machine-id
# 최초 활성화. 구매 후 이메일로 받은 토큰 사용
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX
라이선스를 이미 다른 곳에서 활성화했다면(보통은 개인 용도로 앱에서), --activate는 더 이상 올바른 방법이 아닙니다. 러너는 새 라이선스를 활성화하는 것이 아니라 기존 라이선스에 합류해야 합니다. 이미 활성화된 기기에서 코드를 생성한 뒤, 러너에서 그 코드를 사용하세요.
# 이미 활성화된 기기(앱 또는 다른 CLI)에서 코드를 생성
jpgboost-cli --add-device
# 러너에서 이 코드로 기존 라이선스에 합류
jpgboost-cli --pair XXXX-XXXX
어느 방법을 쓰든 이 활성화는 Pro 라이선스가 포함하는 두 번의 설치 중 하나로 계산됩니다. 앱과 CLI가 별개의 설치로 계산되므로, 작업마다 활성화한다면 할당량은 순식간에 바닥납니다. 단 두 번만 실행해도 라이선스는 이미 다 소진된 셈입니다. 러너가 상시 유지되어야 하는 이유가 바로 이것으로, 활성화 상태를 작업 간에 보존하기 위함입니다.
더 진행하기 전에 로컬에서 간단히 테스트해 봅니다.
# 파일 두 개를 품질 60으로 WebP 변환
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed
GitLab 설정(러너와 푸시 토큰)
아래 .gitlab-ci.yml을 붙여 넣기 전에, GitLab 쪽에서 한 번만 준비해 둘 것이 두 가지 있습니다. 러너 등록, 그리고 수정 작업이 푸시할 때 필요한 토큰 생성입니다.
러너 등록하기
상시 러너로 선택한 Mac에서(앞 절에서 jpgboost-cli와 라이선스를 이미 설치해 둔 그 머신), 두 작업이 함께 사용하는 macos 태그로 러너를 등록합니다.
gitlab-runner register \
--url https://gitlab.com \
--token <PROJECT_REGISTRATION_TOKEN>
등록 토큰은 Settings > CI/CD > Runners > New project runner에 있습니다(화면에서는 예전의 공유 등록 토큰이 아니라, 이번 등록만을 위한 일회용 토큰이 생성됩니다). 여기서 --executor shell이 핵심입니다. Docker 실행기와 달리 러너 시스템에서 명령을 직접 실행하기 때문에, 매번 깨끗한 환경에서 시작하는 대신 jpgboost-cli가 설치되어 있고 라이선스도 활성화된 상태를 작업에서 작업으로 이어받을 수 있습니다.
gitlab-runner register는 러너 이름을, 이어서 실행기를 대화식으로 묻습니다. 마지막 질문에는 shell이라고 답하세요
푸시 토큰 만들기
fix-image-weight 작업은 머지 리퀘스트의 브랜치에 커밋을 푸시합니다. 모든 작업에 자동으로 주어지는 CI_JOB_TOKEN에는 보호된 브랜치에 필요한 쓰기 권한이 없으므로, 전용 토큰이 필요합니다.
Project Access Token(프로젝트의 Settings > Access Tokens)은 이론상 가장 깔끔한 선택입니다. 계정이 아니라 프로젝트에 묶이므로 만든 사람이 떠나도 살아남습니다. 다만 무료 요금제의 개인 네임스페이스에서는 GitLab.com이 이 기능을 제공하지 않고 유료 그룹에만 열어 둡니다. 대신 Personal Access Token을 쓰게 되는 가장 흔한 이유가 바로 이것입니다.
Personal Access Token은 프로젝트 설정이 아니라 사용자 계정 설정에서 만듭니다(Avatar > Edit profile > Access Tokens). 설정 내용은 다음과 같습니다.
- 범위
write_repository - 토큰 교체 정책에 맞는 만료일
이 길을 택하기 전에 알아둘 대가가 있습니다. 토큰은 그것을 만든 계정에 묶여 있다는 점입니다. 그 계정이 비활성화되거나, 프로젝트 접근 권한을 잃거나, 그 사람이 팀을 떠나면 수정 작업은 더 이상 푸시할 수 없게 됩니다. 그것도 사전 경고 없이 말입니다. 팀 프로젝트라면 기여자의 개인 계정보다는 전용 서비스 계정에서 만드는 편이 낫습니다.
생성된 토큰은 단 한 번만 표시됩니다. 즉시 복사한 뒤, 프로젝트의 Settings > CI/CD > Variables에 PUSH_TOKEN이라는 이름으로 추가하세요. 머지 리퀘스트가 보호된 브랜치를 대상으로 한다면 Masked와 Protected 옵션에 체크합니다.
PUSH_TOKEN이며, 아래 .gitlab-ci.yml에서 사용됩니다머지 리퀘스트의 소스 브랜치 자체가 보호되어 있다면, 토큰에 연결된 계정에 그 브랜치로 직접 푸시할 권한도 필요합니다(Settings > Repository > Protected branches > Allowed to push and merge). 그렇지 않으면 토큰이 유효하더라도 작업의 git push는 접근 거부로 실패합니다.
.gitlab-ci.yml 파일을 두는 위치
GitLab은 기본적으로 한 곳에서만 파이프라인을 찾습니다. 저장소 루트에, README와 같은 위치에 있는, 이름이 정확히 .gitlab-ci.yml인 파일(맨 앞의 점 포함)입니다. 하위 폴더에 둔 파일이나 이름이 다른 파일은 그냥 무시됩니다. 오류로 알려 주지도 않고, 프로젝트는 그저 CI가 아예 설정되지 않은 것처럼 동작할 뿐입니다.
다른 위치가 필요하다면, 예컨대 여러 저장소가 공유하는 파이프라인 파일이나 모노레포 구조라면, Settings > CI/CD > General pipelines > CI/CD configuration file에서 다른 경로를 지정할 수 있습니다. 경로/파일.yml@그룹/프로젝트:브랜치 구문으로 다른 프로젝트를 가리킬 수도 있습니다. 이런 특수한 경우가 아니라면 이 칸은 비워 두고 파일은 루트에 두세요.
파일을 커밋하고 푸시하면 별도의 수동 활성화는 필요 없습니다. GitLab은 파일 안의 규칙에 부합하는 다음 이벤트에서 자동으로 감지합니다. 여기서는 $CI_PIPELINE_SOURCE == "merge_request_event"를 통한 머지 리퀘스트의 생성 또는 갱신입니다. 결과는 프로젝트의 Pipelines 탭과, 머지 리퀘스트의 같은 이름 탭에 바로 표시됩니다. 첫 파이프라인이 실패한 뒤에야 알게 되는 대신 푸시 전에 구문을 확인하고 싶다면, 내장 편집기(프로젝트 메뉴의 Build > Pipeline editor)에 Validate 버튼이 있어 실제 파이프라인을 띄우지 않고 GitLab의 CI 린터를 호출할 수 있습니다.
verification과 correction 두 작업으로 구성됩니다GitLab CI 통합
아래 작업은 macos 태그가 붙은 러너, 즉 위에서 설명한 상시 자체 호스팅 러너(파이프라인 밖에서 이미 활성화해 둔)를 대상으로 합니다. 머지 리퀘스트 파이프라인에서만 실행되며, 다음 절에서 소개할 두 가지 모드, 곧 용량 검사와 뒤이은 자동 수정을 결합합니다.
# .gitlab-ci.yml
stages:
- verification
- correction
variables:
# 압축 후 이미지가 이 값을 넘으면 작업을 실패시키는 임계값(KB)
THRESHOLD_KB: "6000"
# 전체 클론: 이것이 없으면 얕은 클론에
# CI_MERGE_REQUEST_DIFF_BASE_SHA가 없어 아래 "git diff"가 실패할 수 있음.
GIT_DEPTH: "0"
check-image-weight:
stage: verification
tags: [macos]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
# 더 진행하기 전에 jpgboost-cli가 응답하는지 확인
jpgboost-cli --help > /dev/null
# 이번 머지 리퀘스트에서 추가되거나 변경된 이미지만 대상으로 함
# (현재 범위: .jpg와 .png)
FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
if [ -z "$FILES" ]; then
echo "이번 머지 리퀘스트에서 변경된 이미지가 없습니다."
exit 0
fi
# 크기 비교를 위해 임시 폴더에 WebP로 시험 압축
mkdir -p /tmp/check-images
# ("readarray"는 쓰지 않음: macOS는 아직도 bash 3.2를 포함하고 있어
# 이 내장 명령이 없음. bash 4에서야 도입됨)
FILES_ARR=()
while IFS= read -r LINE; do
FILES_ARR+=("$LINE")
done <<< "$FILES"
jpgboost-cli "${FILES_ARR[@]}" --format webp --quality 75 --jobs 4 --output /tmp/check-images --json > /tmp/report.json
# 압축 후에도 임계값을 넘는 이미지가 있으면 작업을 실패시킴
THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
OVER_LIMIT=$(jq --argjson threshold "$THRESHOLD_BYTES" '[.[] | select(.compressedSizeBytes > $threshold)] | length' /tmp/report.json)
if [ "$OVER_LIMIT" -gt 0 ]; then
echo "압축 후에도 $THRESHOLD_KB KB를 넘는 이미지가 $OVER_LIMIT개 있습니다:"
jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
exit 1
fi
echo "변경된 모든 이미지가 $THRESHOLD_KB KB 임계값 안에 들어옵니다."
fix-image-weight:
stage: correction
tags: [macos]
# "verification" 스테이지와 분리: 그렇지 않으면 정작 이 작업이 필요한
# 유일한 경우(check-image-weight가 실패할 때)에 결코 도달하지 못함.
needs: []
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
# .jpg/.png만 대상. check-image-weight의 같은 주석 참고.
FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
if [ -z "$FILES" ]; then
echo "이번 머지 리퀘스트에서 변경된 이미지가 없습니다."
exit 0
fi
rm -f /tmp/report.json
THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
MIN_QUALITY=20
REMAINING=""
# 모든 파일을 JPEG로 변환(--format jpeg, 기본값): 사진의 경우 PNG는
# JPEG보다 압축 효율이 눈에 띄게 떨어지며("흔한 함정" 참고), 여기서
# 기대하는 목표 형식이 JPEG임. 투명도가 있는 PNG에는 부적합
# (JPEG에는 알파 채널이 없음). 사진이라면 문제없지만, 언젠가 로고나
# 아이콘이 이 파이프라인을 지나게 된다면 다시 검토할 것.
#
# 결과가 임계값을 넘는 동안 품질을 단계적으로 낮춤: 고정 품질(60)로
# 한 번만 처리해서는 충분하지 않을 때가 있음.
while IFS= read -r FILE; do
FOLDER=$(dirname "$FILE")
QUALITY=60
while :; do
RESULT=$(jpgboost-cli "$FILE" --format jpeg --quality "$QUALITY" --output "$FOLDER" --json)
SIZE=$(echo "$RESULT" | jq '.[0].compressedSizeBytes // 0')
if [ "$SIZE" -le "$THRESHOLD_BYTES" ] || [ "$QUALITY" -le "$MIN_QUALITY" ]; then
break
fi
QUALITY=$((QUALITY - 15))
if [ "$QUALITY" -lt "$MIN_QUALITY" ]; then
QUALITY=$MIN_QUALITY
fi
done
echo "$RESULT" >> /tmp/report.json
# 출력 파일은 항상 <기본이름>.jpg: 원본이 이미 그 확장자가 아니었다면
# (예: .png) 원본을 저장소에서 제거해야 함. 그러지 않으면 둘이 공존하고
# 예전 파일이 무기한 압축되지 않은 채로 남음.
case "$FILE" in
*.jpg) : ;;
*) git rm -q "$FILE" ;;
esac
if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
echo "$FILE은(는) 압축 후에도 $THRESHOLD_KB KB를 넘습니다(품질 $QUALITY, $((SIZE / 1000)) KB): 수동 축소가 필요합니다."
REMAINING="$REMAINING $FILE"
fi
done <<< "$FILES"
# 압축 효과. 작업 로그에서 확인 가능("효과 측정" 참고).
# -s: /tmp/report.json에는 파일마다 JSON 배열이 하나씩 들어 있음
# (위 루프의 ">>" 한 번당 하나). "add"가 합산 전에 하나로 합쳐 줌.
echo "--- 압축 효과 ---"
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) 바이트 (\(.ratio))"' /tmp/report.json
echo "압축 전 합계: $(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) 바이트"
echo "압축 후 합계: $(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) 바이트"
# git diff --quiet은 git rm으로 삭제된 .png가 추적되지 않는 .jpg로
# 대체된 것을 알아채지 못함: status --porcelain은 미추적 파일도 포함함.
if [ -z "$(git status --porcelain)" ]; then
echo "커밋할 것이 없습니다. 모든 이미지가 이미 압축되어 있었습니다."
exit 0
fi
git config user.name "jpgboost-ci"
git config user.email "ci@example.com"
git add -A
# [skip ci]: 이것이 없으면 이 푸시가 새로운 merge_request_event 파이프라인을
# 띄우고, 그것이 또 수정 커밋을 푸시하고... 이런 식으로 무한 루프가 됨.
git commit -m "변경된 이미지를 jpgboost-cli로 압축 [skip ci]"
# PUSH_TOKEN은 personal access token(범위 write_repository)이며 마스킹된
# CI/CD 변수로 저장됨: 기본 CI_JOB_TOKEN으로는 보호된 브랜치에 푸시할 수 없음.
git remote set-url origin "https://gitlab-ci-token:${PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
git push origin "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"
# 가능한 한 최선의 결과는 어떤 경우든 푸시됨(아무것도 안 하는 것보다 나음).
# 다만 최저 품질에서도 임계값을 넘는 파일이 하나라도 남아 있으면 작업을
# 실패시켜, 조용히 넘어가지 않고 눈에 띄도록 함.
if [ -n "$REMAINING" ]; then
echo "압축했는데도 여전히 너무 큰 파일:$REMAINING"
exit 1
fi
검사 모드와 수정 모드
앞 절의 두 작업은 jpgboost-cli를 GitLab 머지 리퀘스트에 연결하는 정반대의 두 가지 방식을 보여 줍니다. 하나는 막아 세우고, 다른 하나는 기여자를 대신해 고쳐 줍니다.
검사 모드에서 check-image-weight 작업은 아무것도 바꾸지 않습니다. 변경된 각 이미지를 임시 폴더에 압축하고, 그 결과 크기를 정해 둔 임계값과 비교한 뒤, 임계값을 넘으면 CI를 실패시킵니다. 저장소는 그대로입니다. 이미지를 다시 다듬어 커밋하는 것은 기여자의 몫이고, 파이프라인은 그 작업이 끝날 때까지 머지를 거부할 뿐입니다.
수정 모드에서 fix-image-weight 작업은 한 걸음 더 나아갑니다. 저장소 안에서 각 이미지를 그 자리에서 직접 압축한 뒤, 결과를 커밋해 머지 리퀘스트 브랜치에 푸시합니다. 기여자가 다시 할 일은 없어지지만, Git 히스토리에는 본인이 쓰지 않은 커밋이 하나 늘어납니다.
| 기준 | 검사 모드 | 수정 모드 |
|---|---|---|
| 목적 | 이미지가 임계값을 넘으면 CI를 막음 | 압축해서 머지 리퀘스트에 커밋을 푸시함 |
| 방식 | --json + jq, 정해 둔 임계값과 비교한 뒤 명시적인 exit 1(내장된 임계값 옵션은 없음) | 직접 압축, 제자리 쓰기, 그다음 푸시 |
| 저장소에 미치는 영향 | 없음. 작업은 관찰만 함 | 머지 리퀘스트에 자동 커밋이 하나 추가됨 |
| 기여자에게 미치는 영향 | 직접 고쳐서 다시 커밋해야 함 | 더 할 일이 없음 |
| 필요한 인증 | 없음 | 마스킹된 변수에 담은 project access token 또는 deploy token |
| 대가 | 마찰이 생기고, 수동 수정의 부담이 기여자에게 남음 | Git 히스토리를 자동으로 덧씀. 보호된 브랜치와 충돌할 위험 |
실무에서 검사 모드는 이미지가 압축된 상태로 저장소에 들어가기 전에 편집상의 판단(크롭, 보정, 형식 선택)을 팀이 계속 쥐고 있고 싶을 때 적합합니다. 수정 모드는 히스토리에 자동 커밋이 하나 늘어나는 것과 푸시 토큰을 관리하는 수고를 감수하더라도, 아예 신경 쓰지 않기를 원하는 팀에 적합합니다.
작업 최적화
두 작업 모두 파이프라인마다 저장소 전체를 다시 훑는 대신, git diff --name-only --diff-filter=ACM을 통해 실제로 변경된 파일로만 작업을 한정하고 있습니다. 이미지가 수백 장 쌓인 저장소라면 실행 시간의 차이는 상당합니다.
상시 자체 호스팅 러너에서는 캐시의 의미가 다릅니다. GitLab CI의 cache: 지시자는 주로 작업마다 처음부터 시작하는 일회성 러너에서 의존성을 복원하기 위해 존재합니다. 여기서는 JPGBoost.app이 이미 러너에 설치되어 있어 다시 내려받을 필요가 없습니다. cache: 블록을 추가해도 러너의 로컬 저장 공간이 이미 제공하는 것 이상은 얻지 못합니다.
--json 옵션은 멱등성 장치를 만드는 데 필요한 정보를 제공합니다. compressedSizeBytes를, 더 나아가 파일의 해시를 실행 간에 보관해 두면, 변경되지 않은 파일을 가려내어 파이프라인마다 다시 압축하는 일을 피할 수 있을 것입니다.
병렬 처리는 --jobs를 통해 기본적으로 지원됩니다. 여러 파일을 동시에 처리할 수 있고, 각각 독립적으로 디코딩되고 해제됩니다. 따라서 메모리 사용량은 대기 중인 파일의 총 개수가 아니라 주로 --jobs 값에 좌우됩니다. 파일 12개 배치에서 문서는 --jobs 8과 --jobs 1 사이에 약 4배의 차이를 보고합니다. 자기 러너에서 이 값을 정할 때 쓸 만한 기준이지만, AVIF와 JPEG XL 인코딩이 JPEG나 HEIC보다 CPU를 훨씬 많이 쓴다는 점은 염두에 두세요.
흔한 함정
첫 번째 함정은 출력 형식과 관련이 있습니다. 위 스크립트는 PNG를 포함한 모든 이미지를 의도적으로 JPEG로 변환합니다. 사진이라면 JPEG가 대체로 PNG보다 품질 대비 크기가 유리합니다. 그래서 --format jpeg를 강제하면 단순히 품질만 낮춰서는 부족한 경우에도 파일을 크기 임계값 아래로 끌어내릴 수 있습니다. 그 대가는 투명도의 상실입니다. JPEG에는 알파 채널이 없기 때문인데, 로그에 반드시 오류가 뜨는 것도 아닙니다. 이 선택은 사진에는 유효하지만, 투명 배경의 PNG 로고나 아이콘을 소리 없이 망가뜨릴 수 있습니다. 두 용도가 섞인 저장소라면 모든 이미지에 단일 형식을 강요하기보다, 작업의 적용 범위를 (예컨대 전용 폴더로) 좁히는 편이 낫습니다.
GitLab CI에서는 특정 스테이지의 작업이 기본적으로 이전 스테이지의 모든 작업이 성공했을 때만 실행됩니다. 그런데 fix-image-weight가 의미를 갖는 것은 check-image-weight가 초과를 감지했을 때이고, 그것이 바로 그 기본 동작으로는 결코 실행되지 않을 경우입니다. needs: []는 수정 작업을 이 암묵적 의존에서 풀어 주어, 검사 작업과 나란히 독립적으로 실행되게 합니다.
그다음 수정 작업은 파이프라인을 띄운 그 브랜치에 커밋을 푸시합니다. 대비하지 않으면 그 푸시가 새로운 머지 리퀘스트 파이프라인을 띄울 수 있습니다. 결과가 여전히 너무 무겁다고 판단되면 작업은 또 고치고, 또 커밋을 푸시하고, 또 파이프라인을 띄웁니다. 이는 몇 분 만에 수십 개의 커밋과 파이프라인을 만들어 내는 루프로 쉽게 번집니다. 자동 커밋의 메시지에 [skip ci]를 넣으면 그 커밋에 대해서는 새 파이프라인을 띄우지 말라고 GitLab에 알리게 됩니다.
CI_MERGE_REQUEST_DIFF_BASE_SHA를 기준으로 실행되는 git diff는 그 기준 커밋이 로컬에 있다는 것도 전제합니다. 그런데 GitLab은 기본적으로 제한된 깊이로 클론합니다. 커밋이 많이 쌓인 머지 리퀘스트이거나 대상 브랜치가 크게 갈라진 경우, 찾는 커밋이 아예 없을 수 있습니다. 그러면 git diff는 이미지 용량과는 아무 상관 없는 이유로 실패합니다. GIT_DEPTH: "0"을 설정하면 전체 클론이 강제되어 이 문제를 피할 수 있지만, 작업마다 클론 시간이 길어지는 대가가 따릅니다.
또 다른 함정은 변경 감지입니다. git diff --quiet은 Git이 이미 추적 중인 파일의 변경만 감지합니다. 그런데 스크립트가 이미지를 JPEG로 변환하고 git rm으로 원본을 지우면, 실제 변경은 두 가지 동작으로 이루어집니다. 추적 중이던 .png의 삭제와, 추적되지 않는 새 .jpg의 생성입니다. 이때 평범한 git diff는 아무것도 보고하지 않을 수 있습니다. 실제로 작업이 로그에 완전히 진짜인 압축 효과를 표시하고서도 "커밋할 것이 없습니다"라고 결론짓고 결과를 끝내 푸시하지 않는 일이 벌어질 수 있습니다. 미추적 파일까지 포함하는 git status --porcelain은 같은 식으로 속지 않습니다.
이미 Git 히스토리에 들어 있는 이미지도 그 자체로 별개의 용량 문제입니다. 새 커밋에서 압축본으로 교체하더라도, 예전 버전은 Git blob으로 히스토리에 남습니다. 저장소가 그 파일들이 차지한 공간을 자동으로 회수하지는 않습니다. 이 오래된 데이터를 없앨 수 있는 것은 히스토리 재작성뿐인데, 그것은 자동화된 파이프라인에 설 자리가 없는 파괴적 작업입니다.
수정 모드의 자동 커밋은 저장소 자체의 규칙과 부딪힐 수도 있습니다. 보호된 브랜치는 직접 푸시를 금지하거나 머지 전 리뷰를 요구할 수 있습니다. 마찬가지로 훅이나 다른 포매팅 장치가 같은 파일을 건드려 압축 작업과 충돌할 수도 있습니다. 이런 상호작용은 마찰 없이 공존하리라 가정하지 말고 명시적으로 테스트해야 합니다.
끝으로, 이 파이프라인이 다루는 것은 저장소를 거치는 이미지, 더 정확히는 해당 머지 리퀘스트 흐름을 거치는 이미지뿐입니다. 디자이너가 이미지를 CMS나 공유 폴더, 그 밖에 Git 바깥의 어떤 자산 저장소에 직접 넣는다면 이 검사는 통째로 우회됩니다. 파이프라인은 저장소에서 버전 관리되는 이미지의 품질을 높여 주지만, 그 자체로 완결된 자산 관리 정책이 되지는 않습니다.
효과 측정
fix-image-weight는 압축 루프 직후에 이 보고서를 이미 자체 로그로 출력합니다(위의 "GitLab CI 통합" 절 참고). 머지 리퀘스트의 Pipelines 탭에서 확인할 수 있고, 따로 추가할 것은 없습니다. 세부 사항으로, --json 출력의 ratio 필드가 originalSizeBytes, compressedSizeBytes와 나란히 파일별 감소율을 바로 알려 줍니다.
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) 바이트 (\(.ratio))"' /tmp/report.json
합계의 경우, 정확한 명령은 작업이 /tmp/report.json을 어떻게 쓰는지에 달려 있습니다. fix-image-weight는 루프를 돌면서 파일마다 JSON 배열을 하나씩 쓰므로(>>), 최종 파일에는 배열 하나가 아니라 여러 배열이 이어 붙은 형태가 담깁니다. 그래서 전체를 읽으려면 -s(slurp)가 필요한데, 이것이 그 배열들을 다시 바깥 배열 하나로 감쌉니다. 먼저 add로 펼치지 않으면 map은 거기서 바로 실패합니다(jq: error: Cannot index array with string). 위 스크립트에서 쓴 형태가 바로 이것입니다.
# 배치 전체의 압축 전후 합계
# (여러 번의 호출로 작성된 파일: fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json
다른 곳에서 직접 보고서를 만든다면, 즉 로컬에서(“jpgboost-cli 설치” 참고)나, 모든 파일을 한 번의 호출로 압축하는 check-image-weight에서라면(>), 그곳의 /tmp/report.json은 이미 단일 JSON 배열입니다. 이때 -s는 필요하지도 옳지도 않습니다. 이미 완성된 배열을 다시 한 겹 감싸기 때문에, 정반대의 이유로 같은 오류가 납니다.
# 한 번의 호출로 작성된 보고서에 대한 같은 계산
# (로컬 테스트, 또는 check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json
jpgboost-cli가 측정하는 것은 스스로 만들어 내는 것, 곧 압축 전후의 크기뿐입니다. 성능 점수(LCP, Lighthouse 점수)는 전용 도구로 따로 얻어야 합니다. 이 도구에 그것을 자체적으로 계산하는 기능은 없으며, 압축률과 인위적으로 엮는 것은 검증되지 않은 외삽에 지나지 않습니다.