FracR · ImageEditor 성능 최적화 노트

속도는 올리고, 화질은 그대로

이미지 편집기 슬라이더가 느려진 원인과, 화질 손실 없이 가볍게 되돌린 방법 — 처음 보는 사람도 이해할 수 있도록

1배경 — 알아야 할 기본 개념

아래 4가지만 알면 이 문서 전체를 이해할 수 있습니다. (웹 캔버스 그래픽 기초)

Canvas & ImageData

브라우저의 그림판이 <canvas>입니다. 그 안의 픽셀들을 통째로 담은 배열이 ImageData로, [R,G,B,A, R,G,B,A, ...] 처럼 픽셀마다 4개 숫자(빨강·초록·파랑·투명도)가 일렬로 들어 있습니다.

필터 = 픽셀 루프

밝기·대비·채도 같은 보정은 모든 픽셀을 하나씩 돌며 색을 다시 계산하는 일입니다. 그래서 픽셀 수가 많을수록 그만큼 느려집니다.

자연 해상도 vs 화면 해상도

자연 해상도 = 사진 원본 크기(예 4000×3000). 화면 해상도 = 편집기에 실제로 보이는 크기(예 800×600). 보통 화면이 원본보다 훨씬 작습니다.

debounce (디바운스)

슬라이더를 드래그하면 이벤트가 초당 수십 번 쏟아집니다. "멈춘 뒤 50ms 후 한 번만" 실행하도록 묶어 호출 횟수를 줄이는 기법입니다.

한 장으로 보는 그림: 편집기는 사진을 캔버스에 올려두고, 슬라이더를 움직일 때마다 ImageData의 모든 픽셀을 돌며 색을 다시 계산합니다. 이때 몇 픽셀짜리 데이터를 도느냐가 속도를 결정합니다. 이 문서는 바로 그 "도는 픽셀 수"를 줄이면서도 화질은 지킨 이야기입니다.

2무엇이 문제였나

코드에는 두 번의 변화가 있었습니다.

① 이전 버전 — 가볍지만 화질이 떨어짐

필터를 화면 해상도(작음)에서 돌려 빨랐습니다. 하지만 크롭/리사이즈할 때 이미 작게 줄여놓은 결과를 또 늘려 쓰는 바람에, 반복할수록 사진이 점점 흐려졌습니다(누적 블러).

② 중간 버전 — 화질은 좋아졌지만 무거워짐

화질을 지키려고 원본(자연 해상도)을 그대로 보관했습니다. 그런데 슬라이더 1틱마다 원본 전체 픽셀(예 1,200만 개)에 필터 루프를 돌게 되어 큰 사진에서 프로그램이 심하게 버벅였습니다.

③ 현재 버전 — 둘 다 잡기 (이 문서)

필터는 다시 화면 해상도에서 가볍게 돌리되, 크롭/리사이즈는 항상 무손실 원본에서 한 번만 줄여 속도와 화질을 동시에 확보했습니다.

핵심 통찰: 최종 저장 결과물은 원래도 화면 해상도였습니다 (canvas.toDataURL()). 그러니 원본 해상도로 필터를 돌리는 것은 출력 화질엔 보탬이 안 되고 비용만 발생시켰던 것입니다.

3핵심 아이디어 — 해상도 분리

"필터를 계산하는 해상도"와 "리샘플의 원본이 되는 해상도"를 분리하고, 세 개의 버퍼에 역할을 나눠 줍니다.

imageData
원본 자연 해상도
필터 미적용 (무손실)
→ 리샘플 원본
correctedImageData
화면 해상도
필터 결과
→ 화면/저장 출력
  • 필터 픽셀 루프는 항상 화면 해상도 previewImageData에서만 → 가볍다
  • 크롭/리사이즈 시에는 깨끗한 원본 imageData에서 딱 1번만 새 화면 크기로 줄임 → 누적 블러 없음
  • 원본 해상도 필터 버퍼를 상시 들고 있지 않으니 메모리도 감소

이 그림에서 imageData → previewImageData 화살표, 즉 "원본을 화면 크기로 한 번 줄이는" 일을 담당하는 함수가 바로 다음 장의 buildPreviewImageData입니다.

4buildPreviewImageData 자세히

이 작은 헬퍼 하나가 최적화의 심장입니다. 하는 일은 단 한 문장:
"무손실 원본(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);
};

한 줄씩 풀어보면

0
가드: 아직 원본이 없으면(이미지 로드 전) 아무것도 안 하고 빠져나갑니다.
1
원본을 캔버스로: 배열 형태의 imageDatadrawImage로 줄이려면 먼저 캔버스에 그려야 합니다. 그래서 자연 해상도 임시 캔버스에 putImageData로 올립니다.
2
목적지 캔버스: 현재 화면에 보이는 크기(objWidth × objHeight)의 빈 캔버스를 준비합니다. 필터가 돌 "작은" 무대입니다.
3
한 번만 축소: drawImage(원본전체 → 화면크기)로 브라우저의 고품질 보간을 이용해 단 한 번 다운스케일합니다. 이미 줄인 것을 또 줄이지 않는 게 핵심 — 그래서 블러가 쌓이지 않습니다.
4
필터 입력 확보: 줄여진 픽셀을 getImageData로 꺼내 previewImageData에 저장. 이후 모든 슬라이더 필터는 이 작은 버퍼만 순회합니다.
왜 가벼워지나: 4000×3000 원본은 1,200만 픽셀이지만, 화면 800×600이면 48만 픽셀. 필터가 도는 양이 약 25배 줄어듭니다. 그런데 화면·저장 결과는 어차피 화면 해상도라 육안 화질 차이 없음.

언제 호출되나 (재생성 시점)

이 함수는 슬라이더를 움직일 때마다가 아니라, 화면에 보이는 크기가 바뀔 때만 호출됩니다.

// changeFilterValue 안의 안전장치 — 사이즈가 바뀌었으면 1회 재생성
if (
    state.previewImageData == null ||
    state.previewImageData.width  !== state.objWidth ||
    state.previewImageData.height !== state.objHeight
) {
    buildPreviewImageData(state);
}
const p = state.previewImageData;   // 이제 필터는 p(화면 해상도)만 순회

5전체 흐름 (라이프사이클)

사용자 동작별로 어떤 함수가 어떤 버퍼를 만지는지 따라가 봅니다. 모두 image.ts 안의 함수입니다.

🖼️ 이미지 로드

drawImageOnFilterCanvas(init = true)

원본을 imageData(자연 해상도)로 캡처 → buildPreviewImageDatapreviewImageData(화면 해상도) 생성 → 화면에 즉시 1회 그림.

🎚️ 슬라이더 드래그 (밝기·대비·채도·화벨·틴트)

changeFilterValue() ← 50ms debounce

작은 previewImageData만 픽셀 루프 → correctedImageData(화면 해상도) 생성 → 화면에 표시. 원본 해상도는 건드리지 않아 가볍다.

✂️ 크롭 / 리사이즈

drawImageOnFilterCanvas(init = false)

깨끗한 imageData(원본)에서 새 화면 크기로 buildPreviewImageData 재생성 → 필터 재적용. 드래그 중엔 직전 결과를 임시로 늘려 보여주고, 50ms 뒤 선명한 결과로 덮어씀. 블러가 쌓이지 않음.

🩹 매직봉 (색 캐스트 제거)

removeColorCast() → getImageDataFromFilterCanvas()

원본을 다시 캡처해 imageData 갱신 → preview 재생성 → 필터 재적용. 역시 화면 해상도에서 처리.

💾 저장 / 업로드

canvas.toDataURL() (EditTabComponent)

화면 캔버스를 그대로 내보냄 = 원래도 화면 해상도. 따라서 필터를 화면 해상도에서 돌려도 최종 결과 화질은 변함없음.

6코드 BEFORE / AFTER

파일: django/frontend/src/FracR/shapes/ImageEditor/image.ts (단일 파일 수정)

BEFORE 무거움

필터가 자연 해상도 전체를 순회

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만 회
  }
};

AFTER 가벼움 · 동일 화질

필터가 화면 해상도 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)는 절대 오염되지 않아 블러가 쌓이지 않음
}

이전 버전은 이미 필터·축소된 결과를 또 늘려서 리샘플 → 반복할수록 흐려졌습니다. 이제는 항상 무손실 원본에서 한 번만 줄이므로 몇 번을 크롭/리사이즈해도 선명합니다.

7직접 체감해 보기

아래 데모는 큰 가상 이미지(2400×1800 ≈ 430만 px)에 밝기·대비 필터를 적용합니다. 모드를 바꿔가며 슬라이더를 움직여 1회 필터 시간(ms)처리 픽셀 수를 비교해 보세요. 화면 결과 화질은 동일합니다.

1회 필터 시간
– ms
처리 픽셀 수

데모의 buildPreview() 함수가 §4의 buildPreviewImageData를 그대로 축소 재현한 것입니다 (원본 → 화면 크기로 drawImage 1회 → getImageData).

8요약

항목① 이전(가벼움)② 중간(무거움)③ 현재(최적)
필터 루프 해상도화면자연 해상도화면
리샘플 원본축소·필터된 결과자연 해상도무손실 원본
속도빠름느림빠름
크롭 반복 화질누적 블러선명선명
메모리적음많음적음

한 줄 결론: 사람이 보는 화면·저장 결과는 어차피 화면 해상도다. 그러니 필터는 화면 해상도에서 계산해 가볍게 하고, 원본 무손실 데이터(imageData)는 리샘플용으로만 보관해 크롭/리사이즈 화질을 지킨다. 이 둘을 잇는 다리가 buildPreviewImageData 다.