이미지 편집기 슬라이더가 느려진 원인과, 화질 손실 없이 가볍게 되돌린 방법 — 처음 보는 사람도 이해할 수 있도록
아래 4가지만 알면 이 문서 전체를 이해할 수 있습니다. (웹 캔버스 그래픽 기초)
브라우저의 그림판이 <canvas>입니다.
그 안의 픽셀들을 통째로 담은 배열이 ImageData로,
[R,G,B,A, R,G,B,A, ...] 처럼 픽셀마다 4개 숫자(빨강·초록·파랑·투명도)가 일렬로 들어 있습니다.
밝기·대비·채도 같은 보정은 모든 픽셀을 하나씩 돌며 색을 다시 계산하는 일입니다. 그래서 픽셀 수가 많을수록 그만큼 느려집니다.
자연 해상도 = 사진 원본 크기(예 4000×3000). 화면 해상도 = 편집기에 실제로 보이는 크기(예 800×600). 보통 화면이 원본보다 훨씬 작습니다.
슬라이더를 드래그하면 이벤트가 초당 수십 번 쏟아집니다. "멈춘 뒤 50ms 후 한 번만" 실행하도록 묶어 호출 횟수를 줄이는 기법입니다.
코드에는 두 번의 변화가 있었습니다.
필터를 화면 해상도(작음)에서 돌려 빨랐습니다. 하지만 크롭/리사이즈할 때 이미 작게 줄여놓은 결과를 또 늘려 쓰는 바람에, 반복할수록 사진이 점점 흐려졌습니다(누적 블러).
화질을 지키려고 원본(자연 해상도)을 그대로 보관했습니다. 그런데 슬라이더 1틱마다 원본 전체 픽셀(예 1,200만 개)에 필터 루프를 돌게 되어 큰 사진에서 프로그램이 심하게 버벅였습니다.
필터는 다시 화면 해상도에서 가볍게 돌리되, 크롭/리사이즈는 항상 무손실 원본에서 한 번만 줄여 속도와 화질을 동시에 확보했습니다.
핵심 통찰: 최종 저장 결과물은 원래도 화면 해상도였습니다
(canvas.toDataURL()). 그러니 원본 해상도로 필터를 돌리는 것은
출력 화질엔 보탬이 안 되고 비용만 발생시켰던 것입니다.
"필터를 계산하는 해상도"와 "리샘플의 원본이 되는 해상도"를 분리하고, 세 개의 버퍼에 역할을 나눠 줍니다.
previewImageData에서만 → 가볍다imageData에서 딱 1번만 새 화면 크기로 줄임 → 누적 블러 없음이 그림에서 imageData → previewImageData 화살표,
즉 "원본을 화면 크기로 한 번 줄이는" 일을 담당하는 함수가 바로 다음 장의 buildPreviewImageData입니다.
이 작은 헬퍼 하나가 최적화의 심장입니다. 하는 일은 단 한 문장:
"무손실 원본(imageData)을 현재 화면 크기로 딱 한 번 줄여서 필터용 버퍼(previewImageData)를 만든다."
실제 코드 — django/frontend/src/FracR/shapes/ImageEditor/image.ts
// pristine 자연 해상도 imageData 를 현재 디스플레이 사이즈(objWidth × objHeight)로
// 1회 다운스케일해 previewImageData 에 저장한다. 필터 픽셀 루프는 이 디스플레이
// 해상도 버퍼에서만 돌아 가볍다. 리샘플 원본이 항상 pristine 자연 해상도라
// 크롭/리사이즈를 반복해도 누적 블러가 생기지 않는다.
const buildPreviewImageData = (state: ImageState) => {
if (state.imageData === null || state.imageData === undefined) return;
// 1) 원본 픽셀(imageData)을 자연 해상도 임시 캔버스에 올린다
const naturalCanvas = document.createElement('canvas') as HTMLCanvasElement;
naturalCanvas.width = state.imageData.width;
naturalCanvas.height = state.imageData.height;
(naturalCanvas.getContext('2d') as CanvasRenderingContext2D)
.putImageData(state.imageData, 0, 0);
// 2) 화면 크기(objWidth × objHeight)의 임시 캔버스를 만들고
const previewCanvas = document.createElement('canvas') as HTMLCanvasElement;
previewCanvas.width = state.objWidth;
previewCanvas.height = state.objHeight;
const previewCtx = previewCanvas.getContext('2d') as CanvasRenderingContext2D;
// 3) 원본 → 화면 크기로 drawImage 한 번 (브라우저가 고품질 축소 처리)
previewCtx.drawImage(
naturalCanvas,
0, 0, state.imageData.width, state.imageData.height, // src: 원본 전체
0, 0, state.objWidth, state.objHeight // dst: 화면 크기
);
// 4) 줄여진 화면 해상도 픽셀을 꺼내 필터 입력 버퍼로 저장
state.previewImageData = previewCtx.getImageData(0, 0, state.objWidth, state.objHeight);
};
imageData를 drawImage로 줄이려면 먼저 캔버스에 그려야 합니다. 그래서 자연 해상도 임시 캔버스에 putImageData로 올립니다.objWidth × objHeight)의 빈 캔버스를 준비합니다. 필터가 돌 "작은" 무대입니다.drawImage(원본전체 → 화면크기)로 브라우저의 고품질 보간을 이용해 단 한 번 다운스케일합니다. 이미 줄인 것을 또 줄이지 않는 게 핵심 — 그래서 블러가 쌓이지 않습니다.getImageData로 꺼내 previewImageData에 저장. 이후 모든 슬라이더 필터는 이 작은 버퍼만 순회합니다.이 함수는 슬라이더를 움직일 때마다가 아니라, 화면에 보이는 크기가 바뀔 때만 호출됩니다.
drawImageOnFilterCanvas의 init 분기)objWidth/objHeight가 바뀔 때 (else 분기)getImageDataFromFilterCanvas)changeFilterValue 안에서 preview 크기가 화면 크기와 다르면 자동 재생성// changeFilterValue 안의 안전장치 — 사이즈가 바뀌었으면 1회 재생성
if (
state.previewImageData == null ||
state.previewImageData.width !== state.objWidth ||
state.previewImageData.height !== state.objHeight
) {
buildPreviewImageData(state);
}
const p = state.previewImageData; // 이제 필터는 p(화면 해상도)만 순회
사용자 동작별로 어떤 함수가 어떤 버퍼를 만지는지 따라가 봅니다. 모두 image.ts 안의 함수입니다.
원본을 imageData(자연 해상도)로 캡처 → buildPreviewImageData로 previewImageData(화면 해상도) 생성 → 화면에 즉시 1회 그림.
작은 previewImageData만 픽셀 루프 → correctedImageData(화면 해상도) 생성 → 화면에 표시. 원본 해상도는 건드리지 않아 가볍다.
깨끗한 imageData(원본)에서 새 화면 크기로 buildPreviewImageData 재생성 → 필터 재적용.
드래그 중엔 직전 결과를 임시로 늘려 보여주고, 50ms 뒤 선명한 결과로 덮어씀. 블러가 쌓이지 않음.
원본을 다시 캡처해 imageData 갱신 → preview 재생성 → 필터 재적용. 역시 화면 해상도에서 처리.
화면 캔버스를 그대로 내보냄 = 원래도 화면 해상도. 따라서 필터를 화면 해상도에서 돌려도 최종 결과 화질은 변함없음.
파일: django/frontend/src/FracR/shapes/ImageEditor/image.ts (단일 파일 수정)
필터가 자연 해상도 전체를 순회
const changeFilterValue = (cv,ev,sv,wbv,tv, state) => {
// imageData = 자연 해상도 (예 1200만 px)
state.correctedImageData =
ctx.createImageData(
state.imageData.width,
state.imageData.height);
for (let i=0;
i < state.imageData.data.length; // 전체!
i += 4) {
// contrast/saturation/whiteBalance…
// 슬라이더 1틱마다 1200만 회
}
};
필터가 화면 해상도 preview만 순회
const changeFilterValue = (cv,ev,sv,wbv,tv, state) => {
if (size 바뀜) buildPreviewImageData(state);
const p = state.previewImageData; // 48만 px
state.correctedImageData =
ctx.createImageData(p.width, p.height);
for (let i=0;
i < p.data.length; // preview만!
i += 4) {
// 동일 필터, 25배 적은 픽셀
}
};
else if (state.imageData) {
// ✅ 깨끗한 원본(자연 해상도)에서 새 화면 크기로 1회 재리샘플
buildPreviewImageData(state);
// 드래그 중 즉시 페인트는 직전 결과를 임시로 늘려 보여주고,
// 50ms 뒤 debounce 된 changeFilterValue 가 선명하게 덮어쓴다.
// → 권위 버퍼(imageData)는 절대 오염되지 않아 블러가 쌓이지 않음
}
이전 버전은 이미 필터·축소된 결과를 또 늘려서 리샘플 → 반복할수록 흐려졌습니다. 이제는 항상 무손실 원본에서 한 번만 줄이므로 몇 번을 크롭/리사이즈해도 선명합니다.
아래 데모는 큰 가상 이미지(2400×1800 ≈ 430만 px)에 밝기·대비 필터를 적용합니다. 모드를 바꿔가며 슬라이더를 움직여 1회 필터 시간(ms)과 처리 픽셀 수를 비교해 보세요. 화면 결과 화질은 동일합니다.
데모의 buildPreview() 함수가 §4의 buildPreviewImageData를 그대로 축소 재현한 것입니다
(원본 → 화면 크기로 drawImage 1회 → getImageData).
| 항목 | ① 이전(가벼움) | ② 중간(무거움) | ③ 현재(최적) |
|---|---|---|---|
| 필터 루프 해상도 | 화면 | 자연 해상도 | 화면 |
| 리샘플 원본 | 축소·필터된 결과 | 자연 해상도 | 무손실 원본 |
| 속도 | 빠름 | 느림 | 빠름 |
| 크롭 반복 화질 | 누적 블러 | 선명 | 선명 |
| 메모리 | 적음 | 많음 | 적음 |
한 줄 결론: 사람이 보는 화면·저장 결과는 어차피 화면 해상도다. 그러니 필터는 화면 해상도에서 계산해 가볍게 하고, 원본 무손실 데이터(imageData)는 리샘플용으로만 보관해 크롭/리사이즈 화질을 지킨다. 이 둘을 잇는 다리가 buildPreviewImageData 다.