병원을 주 고객층으로 하는 서비스에서 이미지 템플릿 업로드 기능을 구현했습니다.
DSLR 카메라로 촬영된 고용량 이미지를 자주 다루게 되면서, 성능 최적화가 필요한 상황이 발생했습니다.
QA 팀과 함께 98MB의 고용량 이미지를 사용하여 테스트를 진행하였고, 블로킹 현상이 나타서 이를 개선하는 과정을 정리해보았습니다.
💡 레이아웃 쉬프트 관련 이슈는 다루지 않습니다.
현상:
구체적인 측정 결과:
이미지 로드 소요시간: 12초
프레임 드롭 현상:
98MB라는 극단적인 케이스였지만, 실제 운영 환경에서는 이와 비슷하지만 좀 더 약하게 발생할 수 있는 시나리오라고 판단하여 개선이 필요했습니다.
(이후 db를 확인해보니 최대 용량은 150MB였습니다.)
이미지 태그의 URL 요청에 대한 성능을 분석한 결과, 네트워크 요청 단계별로 다음과 같은 특징을 보였습니다:
1. 초기 상태 (fetchPriority 적용 전)
💡 Initial Priority는 네트워크 대역폭 할당과 메인 스레드 처리 우선순위에 직접적인 영향을 미칩니다.
2. fetchPriority='high' 적용 후
우선순위 상향 조정으로 전체 처리 시간이 약 1.7초 감소했습니다.
추가로 fetch API를 통한 선행 요청 테스트에서도 비슷한 성능 향상을 확인했습니다.
object-fit: contain 속성 사용을 사용하여 부모 요소 크기에 맞춘 렌더링으로 레이아웃 쉬프트를 최대한 방지하였습니다.
object-fit: contain이란?
이미지의 원본 비율을 유지하면서 컨테이너 내에 전체 이미지를 표시하는 속성입니다. 단, 컨테이너를 완전히 채우지 않을 수 있습니다.
실제 이미지 태그에 그려진 이미지를 확인해보니 몇가지 문제점이 있었습니다.
문제점:
이러한 분석을 바탕으로 이미지 리사이징 작업의 필요성이 확인되었습니다.
메인 스레드 블로킹을 방지하기 위해 비동기 방식의 이미지 리사이징 전략을 구현했습니다.
다음과 같이 3단계로 진행하였습니다.
구현 코드:
const optimizeImage = async (img: HTMLImageElement, objectUrl: string): Promise<string> => {
// 이미지가 최대 크기보다 작으면 원본 반환
if (img.width <= IMAGE_CONFIG.MAX_SIZE && img.height <= IMAGE_CONFIG.MAX_SIZE) {
return objectUrl;
}
// Canvas 설정
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 비율 계산 및 캔버스 크기 지정
const ratio = Math.min(
IMAGE_CONFIG.MAX_SIZE / img.width,
IMAGE_CONFIG.MAX_SIZE / img.height
);
canvas.width = img.width * ratio;
canvas.height = img.height * ratio;
// 이미지 그리기 및 최적화된 URL 생성
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
URL.revokeObjectURL(objectUrl);
const optimizedUrl = URL.createObjectURL(blob);
resolve(optimizedUrl);
} else {
reject(new Error('Failed to create optimized image'));
}
},
'image/jpeg',
IMAGE_CONFIG.JPEG_QUALITY,
);
});
};
최적화 결과:
품질 유지:
이미지 해상도와 파일크기를 줄임으로서 성능을 개선할 수 있었습니다.
이미지 최적화 적용 전후의 성능을 여러 측면에서 비교 분석했습니다.
개선 전
개선 후
개선 전
개선 후
💡 자바스크립트의 긴 작업은 메인 스레드를 블로킹하여 사용자 인터랙션을 방해할 수 있습니다.
개선 전
개선 후
이러한 최적화를 통해 전반적인 사용자 경험이 크게 개선되었으며, 특히 고용량 이미지 처리 시의 성능 병목 현상을 효과적으로 해결할 수 있었습니다.
이미지 태그에 blob 데이터를 직접 전달할 수 없어, 다음 두 가지 방식을 검토했습니다:
Base64 인코딩 방식
URL.createObjectURL 방식 ✅
// 메모리 관리 예시
useEffect(() => {
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [objectUrl]);
문제 상황:
해결 방안:
abortController를 사용하여 이전 요청의 응답을 받지 않고,
현재 사용하는 응답만 받도록 작성하였습니다.
const ImageLoader = () => {
const abortControllerRef = useRef<AbortController | null>(null);
const optimizedUrlRef = useRef<string | null>(null);
const loadImage = async (imageUrl: string) => {
// 이전 요청 중단 및 메모리 정리
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (optimizedUrlRef.current) {
URL.revokeObjectURL(optimizedUrlRef.current);
}
// 새로운 요청 설정
abortControllerRef.current = new AbortController();
try {
const response = await fetch(imageUrl, {
cache: 'force-cache',
signal: abortControllerRef.current.signal
});
// 이미지 처리 로직...
} catch (error) {
if (error.name === 'AbortError') {
console.log('이미지 로딩이 취소되었습니다.');
}
}
};
// ...
};
이러한 최적화를 통해 메모리 누수를 방지하고, 잘못된 이미지 표기하는 문제를 해결할 수 있었습니다.
현재는 이미지 최적화를 output 단계에서 처리하고 있지만, 이는 근본적인 해결책이 아닐 수 있다는 점을 깨달았습니다.
서버로 전송되는 원본 이미지의 크기가 커도 실제로 보여지는 이미지는 그렇지 않기때문에, 불필요하게 사용되고 있습니다.
이미지 업로드 시점(input)에서 최적화를 진행하면 다음과 같은 이점이 있습니다:
프론트엔드 리더분과 협의하여 다음과 같은 방향으로 개선을 진행하기로 했습니다:
이번 이미지 최적화 프로젝트를 통해 많은 것을 배우고 성장할 수 있었습니다.
병원이라는 특수한 환경에서 DSLR 카메라로 촬영한 고용량 이미지를 고려하면서, 기술적 해결책을 찾아가는 과정은 값진 경험이었습니다.
이미지 최적화에 대한 새로운 시각을 얻을 수 있었습니다.
처음에는 단순히 width
와 height
속성 조정으로 해결될 것이라 생각했지만, Canvas를 활용한 실제 이미지 리사이징의 필요성과 그 효과를 직접 경험해볼 수 있었습니다.
Chrome Performance 탭을 활용한 성능 분석 경험도 좋았습니다.
구체적인 수치를 통해 개선사항을 확인해볼 수 있었습니다.
AbortController를 활용한 불필요한 네트워크 요청 제어와 Base64와 createObjectURL의 비교 분석을 통한 최적의 방법을 찾아가는 경험도 기억에 남았습니다.
끝으로 아쉬운 점도 복기하면서 더 좋은 방법까지 고려할 수 있어서 좋았습니다.
감사합니다!
출처: