개요
[1] 요약
[2] 기능 소개
[3] 코드
[1] 요약
이미지 컴포넌트는 사용중인 라이브러리꺼 쓰는게 제일 좋지만 이번에 직접 한 번 만들어 봤다. 이번에 만든 이미지 컴포넌트를 짧게 설명하자면 다음과 같다.
다양한 로딩 전략을 지원하고 성능 최적화를 위한 여러 기능들을 내장하고 있다. 또한, 모듈식 구조로 설계되어 필요에 따라 기능을 선택적으로 사용할 수 있어 번들 크기를 최소화할 수 있다.
[2] 기능 소개
1. 이미지 스타일
기본적으로 프롭스에서 fit, position, center, blur 값을 받아 실제 이미지 태그에 전달한다. 기본 값이 설정되어 있어 값을 따로 주지 않아도 기본적인 스타일이 적용된다. 추가적으로 반응형 이미지를 위한 srcSet과 sizes 속성도 프롭스로 받아올 수 있다.
2. 접근성 향상
웹 접근성을 위한 다양한 기능을 제공한다. 이미지 상태에 따라 적절한 ARIA 속성(aria-busy, aria-hidden)을 설정한다. 이를 통해 스크린 리더 사용자를 포함한 모든 사용자에게 더 나은 경험을 제공한다.
3. 이중 로딩 전략
해당 컴포넌트는 두 가지 주요 로딩 전략을 지원한다.
1) 네이티브 레이지 로딩(lazy 속성)을 통해 브라우저의 내장 기능을 활용하여 뷰포트에 들어올 때만 이미지를 로드
2) React Suspense 통합(suspense 속성)을 통해 선언적인 비동기 로딩을 지원한다.
4. 캐싱 시스템
내장된 이미지 캐싱 시스템은 LRU(Least Recently Used) 알고리즘을 사용하여 메모리 사용을 최적화한다. 캐시 크기 제한과 만료 시간 설정을 통해 메모리 누수를 방지하며, 이미 로드된 이미지를 재사용하여 네트워크 요청을 줄인다. 캐싱 시스템은 필요에 따라 비활성화할 수도 있다.
5. 에러 처리 및 대체 이미지
이미지 로드 실패를 효과적으로 처리하기 위해 내장된 에러 바운더리를 제공한다. fallbackSrc 속성을 통해 로드 실패 시 표시할 대체 이미지를 지정할 수 있으며, 타임아웃 기능으로 무한 로딩을 방지한다.
6. 성능 최적화
여러 성능 최적화 기법을 통합하였다. contain: content(CSS 속성)을 사용하여 렌더링을 격리하고, 선택적 GPU 가속을 제공한다(enableGpu). 이미지 디코딩은 비동기적으로 처리되며(decoding: async), 불필요한 리렌더링을 방지하기 위해 useMemo를 사용한다. 이러한 최적화를 통해 대량의 이미지를 표시할 때도 부드러운 사용자 경험을 제공한다.
[3] 코드
기존에 Javascript를 사용해 구현했는데 혼자 쓸 땐 상관이 없었지만 이렇게 공유할 때는 프롭스 타입 지정이 필요한 것 같아 jsDocs를 통해 타입을 지정했다. 그리고 코드에 대한 설명은 개념을 알아야 하는 것이라 적기에는 너무 많아서 적지 않기로 했다. 그리고 처음에는 파일 하나에서 다 적었었는데 자꾸 뭐가 추가되다보니 600줄까지 불어나 분리했다.
코드는 아래의 구조로 되어있다.
Image
├── constants.js
├── Image.jsx
├── imageCache.js
├── imageCache-core.js
├── RegularImage.jsx
├── styles.jsx
├── SuspenseImage.jsx
Image.jsx
import { Suspense, lazy, useMemo } from 'react';
const lazyWithNamedExport = (modulePromise, namedExport) => {
return lazy(() =>
modulePromise.then((module) => ({
default: module[namedExport],
}))
);
};
const SuspenseImage = lazyWithNamedExport(
import('@/components/Image/SuspenseImage.jsx'),
'SuspenseImage'
);
const RegularImage = lazyWithNamedExport(
import('@/components/Image/RegularImage.jsx'),
'RegularImage'
);
/**
* @typedef {Object} ImageProps
*
* @property {string} src - 이미지 소스 URL (필수)
* @property {string} alt - 이미지 대체 텍스트 (접근성을 위해 필수)
* @property {string} [width] - 이미지 컨테이너 너비
* @property {string} [height] - 이미지 컨테이너 높이
* @property {string} [srcSet] - 반응형 이미지를 위한 소스 세트
* @property {string} [sizes] - 반응형 이미지를 위한 사이즈 힌트
*
* @property {Object} [style] - 추가 인라인 스타일
* @property {'cover'|'contain'|'fill'|'none'} [fit='cover'] - 이미지 핏
* @property {string} [position='center'] - 이미지 위치 (center, top left 등)
* @property {boolean} [center=false] - 중앙 정렬 여부
* @property {number} [blur=0] - 블러 효과 정도 (픽셀)
* @property {string|number} [borderRadius=4] - 모서리 둥글기
* @property {string} [backgroundColor='transparent'] - 배경색
* @property {string} [placeholderColor='#f0f0f0'] - 로딩 중 플레이스홀더 색상
*
* @property {boolean} [lazy=true] - 네이티브 레이지 로딩 사용 여부
* @property {boolean} [suspense=false] - React Suspense 사용 여부
* @property {React.ReactNode} [suspenseFallback] - Suspense 폴백 컴포넌트
* @property {boolean} [enableGpu=false] - GPU 가속 활성화 여부
*
* @property {Function} [onClick] - 클릭 이벤트 핸들러
* @property {Function} [onLoad] - 이미지 로드 완료 이벤트 핸들러
* @property {Function} [onError] - 이미지 로드 실패 이벤트 핸들러
*
* @property {string} [fallbackSrc] - 이미지 로드 실패 시 대체 이미지
* @property {string} [locale='ko'] - 오류 메시지 언어 설정
*/
/**
* 다양한 로딩 전략과 편의성을 지원하는 이미지 컴포넌트
*
* @param {ImageProps} props - 이미지 컴포넌트 속성
* @returns {React.ReactElement} 이미지 컴포넌트
*
* @example
* // 기본 사용법
* <Image src="/example.jpg" alt="예시 이미지" />
*
* @example
* // Suspense 모드 사용
* <Image
* src="/hero.jpg"
* alt="히어로 이미지"
* suspense={true}
* width="100%"
* height="300px"
* />
*/
// export const Image: React.FC<ImageProps>
export const Image = ({ suspense, width, height, ...restProps }) => {
const ImageComponent = useMemo(() => {
return suspense ? SuspenseImage : RegularImage;
}, [suspense]);
return (
<Suspense
fallback={
<div
style={{
width: width,
height: height,
}}
/>
}
>
<ImageComponent width={width} height={height} {...restProps} />
</Suspense>
);
};
styles.jsx
import { ImageStatus } from '@/components/Image/constants.js';
import styled from '@emotion/styled';
export const ImageContainer = styled.div`
position: relative;
width: ${(props) => props.width || '100%'};
height: ${(props) => props.height || 'auto'};
overflow: ${(props) => props.overflow || 'hidden'};
background-color: ${(props) => props.backgroundColor || 'transparent'};
border-radius: ${(props) => props.borderRadius || '0'};
contain: layout paint style;
${(props) =>
props.enableGpu
? `
transform: translateZ(0);
will-change: transform;
`
: ''}
`;
export const RealImage = styled.img`
display: block;
opacity: ${(props) => (props.status === ImageStatus.LOADED ? 1 : 0)};
filter: ${(props) => (props.blur ? `blur(${props.blur}px)` : 'none')};
decoding: async;
width: ${(props) =>
['contain', 'cover', 'fill'].includes(props.fit) ? '100%' : 'auto'};
height: ${(props) =>
['contain', 'cover', 'fill'].includes(props.fit) ? '100%' : 'auto'};
object-fit: ${(props) => props.fit || 'cover'};
object-position: ${(props) => props.position || 'center'};
transition: opacity 0.3s ease;
will-change: opacity;
${(props) =>
props.center &&
`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
`}
`;
export const Placeholder = styled.div`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background-color: ${(props) => props.placeholderColor || '#f0f0f0'};
opacity: ${(props) => (props.status === ImageStatus.LOADED ? 0 : 1)};
transition: opacity 0.3s ease;
willl-change: opacity;
pointer-events: none;
`;
constants.js
export const ImageStatus = {
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
export const LOAD_TIMEOUT = 10000;
export const errorMessages = {
ko: {
loadFailed: '이미지 로드 실패',
timeout: '이미지 로드 타임아웃',
altFallback: '이미지 로드 실패를 보여주는 이미지',
},
en: {
loadFailed: 'image loading failed',
timeout: 'image loading timed out',
altFallback: 'image showing load failure',
},
};
export const defaultLocale = 'ko';
RegularImage.jsx
import { ImageStatus } from '@/components/Image/constants.js';
import {
ImageContainer,
Placeholder,
RealImage,
} from '@/components/Image/styles.jsx';
import { useState } from 'react';
const imageCache = new Map();
const cacheSet = (key, value) => {
if (key) {
if (imageCache.size > 100) {
const firstKey = imageCache.keys().next().value;
imageCache.delete(firstKey);
}
imageCache.set(key, {
...value,
timestamp: Date.now(),
});
}
};
const cacheGet = (key) => (key ? imageCache.get(key) : undefined);
const cacheHas = (key) => (key ? imageCache.has(key) : false);
export const RegularImage = ({
src,
alt,
width,
style,
height,
onLoad,
onClick,
onError,
fallbackSrc,
fit = 'cover',
blur = 0,
lazy = true,
center = false,
position = 'center',
borderRadius = '4',
backgroundColor = 'transparent',
placeholderColor = '#f0f0f0',
enableGpu = false,
srcSet,
sizes,
...props
}) => {
const isCached =
cacheHas(src) && cacheGet(src).complete && !cacheGet(src).error;
const [imageStatus, setImageStatus] = useState(
isCached
? ImageStatus.LOADED
: lazy
? ImageStatus.LOADING
: ImageStatus.LOADED
);
const handleLoad = (e) => {
setImageStatus(ImageStatus.LOADED);
cacheSet(src, {
complete: true,
error: null,
result: src,
});
if (onLoad) {
onLoad(e);
}
};
const handleError = (e) => {
setImageStatus(ImageStatus.ERROR);
cacheSet(src, {
complete: true,
error: null,
result: src,
});
if (onError) {
onError(e);
}
};
return (
<ImageContainer
width={width}
height={height}
backgroundColor={backgroundColor}
borderRadius={borderRadius}
onClick={onClick}
style={style}
enableGpu={enableGpu}
{...props}
>
<Placeholder status={imageStatus} placeholderColor={placeholderColor} />
<RealImage
src={
imageStatus === ImageStatus.ERROR && fallbackSrc ? fallbackSrc : src
}
srcSet={srcSet}
sizes={sizes}
alt={alt}
fit={fit}
position={position}
center={center}
status={imageStatus}
blur={blur}
loading={lazy ? 'lazy' : 'eager'}
onLoad={handleLoad}
onError={handleError}
aria-busy={imageStatus === ImageStatus.LOADING}
aria-hidden={imageStatus === ImageStatus.ERROR && !fallbackSrc}
/>
</ImageContainer>
);
};
SuspenseImage.jsx
import {
ImageStatus,
LOAD_TIMEOUT,
defaultLocale,
errorMessages,
} from '@/components/Image/constants.js';
import {
ImageContainer,
Placeholder,
RealImage,
} from '@/components/Image/styles.jsx';
import { Component, Suspense } from 'react';
class ImageErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback || null;
}
return this.props.children;
}
}
let imageCachePromise = null;
const getCache = () => {
if (!imageCachePromise) {
imageCachePromise = import('@/components/Image/imageCache-core.js').then(
(module) => module.imageCache
);
}
return imageCachePromise;
};
const preloadImage = async (src, fallbackSrc, locale) => {
if (!src) {
return null;
}
throw getCache().then((imageCache) => {
if (imageCache.has(src)) {
const cached = imageCache.get(src);
if (cached.error && fallbackSrc) {
return fallbackSrc;
}
if (cached.error) {
throw cached.error;
}
if (cached.complete) {
return cached.result;
}
throw cached.promise;
}
const entry = {
complete: false,
error: null,
result: null,
promise: null,
};
const promise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
const errorMsg =
locale && errorMessages[locale]?.timeout
? errorMessages[locale].timeout
: errorMessages.en.timeout;
const timeoutError = new Error(`${errorMsg}: ${src}`);
entry.complete = true;
entry.error = timeoutError;
reject(timeoutError);
}, LOAD_TIMEOUT);
const img = new Image();
img.onload = () => {
clearTimeout(timeoutId);
entry.complete = true;
entry.result = src;
resolve(src);
};
img.onerror = () => {
clearTimeout(timeoutId);
entry.complete = true;
const errorMsg =
locale && errorMessages[locale]?.loadFailed
? errorMessages[locale].loadFailed
: errorMessages.en.loadFailed;
entry.error = new Error(`${errorMsg}: ${src}`);
if (fallbackSrc) {
entry.result = fallbackSrc;
entry.error = null;
resolve(fallbackSrc);
} else {
reject(entry.error);
}
};
// 크로스 오리진 설정
if (
img.crossOrigin !== undefined &&
src.startsWith('http') &&
!src.includes(window.location.hostname)
) {
img.crossOrigin = 'anonymous';
}
img.src = src;
}).catch((error) => {
if (fallbackSrc && fallbackSrc !== src) {
return preloadImage(fallbackSrc, null, locale);
}
throw error;
});
entry.promise = promise;
imageCache.set(src, entry);
throw promise;
});
};
const SuspenseImageRenderer = ({
src,
fallbackSrc,
alt,
locale = defaultLocale,
...props
}) => {
const imageSrc = preloadImage(src, fallbackSrc, locale);
return (
<RealImage
src={imageSrc}
alt={alt}
status={ImageStatus.LOADED}
{...props}
/>
);
};
export const SuspenseImage = ({
src,
alt,
width,
style,
height,
fallbackSrc,
fit = 'cover',
blur = 0,
center = false,
position = 'center',
borderRadius = '4',
backgroundColor = 'transparent',
placeholderColor = '#f0f0f0',
suspenseFallback = null,
locale = defaultLocale,
enableGpu = false,
onClick,
...props
}) => {
return (
<ImageContainer
width={width}
height={height}
backgroundColor={backgroundColor}
borderRadius={borderRadius}
onClick={onClick}
style={style}
enableGpu={enableGpu}
{...props}
>
<ImageErrorBoundary
fallback={
<RealImage
src={fallbackSrc || null}
alt={
alt ||
errorMessages[locale]?.altFallback ||
errorMessages.en.altFallback
}
fit={fit}
position={position}
center={center}
blur={blur}
status={ImageStatus.LOADED}
/>
}
>
<Suspense
fallback={
suspenseFallback || (
<Placeholder
status={ImageStatus.LOADING}
placeholderColor={placeholderColor}
/>
)
}
>
<SuspenseImageRenderer
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
fit={fit}
position={position}
center={center}
blur={blur}
locale={locale}
/>
</Suspense>
</ImageErrorBoundary>
</ImageContainer>
);
};
imageCache.js
export const getImageCache = async () => {
const { imageCache } = await import('@/components/Image/imageCache-core.js');
return imageCache;
};
export const configureImageCache = async (config) => {
const { configureImageCache: configure } = await import(
'@/components/Image/imageCache-core.js'
);
return configure(config);
};
imageCache-core.js
const DEFAULT_CONFIG = {
maxSize: 100,
expiryTime: 5 * 60 * 1000,
enabled: true,
};
let config = { ...DEFAULT_CONFIG };
class ImageCacheManager {
constructor() {
this.cache = new Map();
this.lruOrder = [];
}
set(key, value) {
if (!config.enabled) {
return;
}
if (!this.cache.has(key) && this.lruOrder.length >= config.maxSize) {
const oldest = this.lruOrder.shift();
this.cache.delete(oldest);
}
const existingIndex = this.lruOrder.indexOf(key);
if (existingIndex > -1) {
this.lruOrder.splice(existingIndex, 1);
}
this.cache.set(key, { ...value, timestamp: Date.now() });
this.lruOrder.push(key);
}
get(key) {
if (!config.enabled) return undefined;
const item = this.cache.get(key);
if (!item) return undefined;
// 만료된 캐시 항목 체크
if (Date.now() - item.timestamp > config.expiryTime) {
this.cache.delete(key);
const index = this.lruOrder.indexOf(key);
if (index > -1) this.lruOrder.splice(index, 1);
return undefined;
}
// LRU 순서 업데이트
const index = this.lruOrder.indexOf(key);
if (index > -1) {
this.lruOrder.splice(index, 1);
this.lruOrder.push(key);
}
return item;
}
has(key) {
if (!config.enabled) return false;
return (
this.cache.has(key) &&
Date.now() - this.cache.get(key).timestamp <= config.expiryTime
);
}
clear() {
this.cache.clear();
this.lruOrder = [];
}
}
export const imageCache = new ImageCacheManager();
만들면서 자꾸 욕심히 생겨서, 현 상태에서 알고있는 모든걸 쏟아 부었고 아이디어가 생각나는대로 다 넣었다.코드가 알고리즘 문제처럼 엄청 어려운건 아닌데 이해하기 위해 알아야하는 개념이 많은 것 같다. 그리고 사실 만든지 얼마 안되서 아직 많이 사용도 못해봤다. (03/15/2025)
'Frontend > React' 카테고리의 다른 글
[React] undo, redo, rollback을 지원하는 미니멀 편집기 구현하기 (content editable, selection, range, useRef) (0) | 2025.03.18 |
---|---|
[React] useTransition, Concurrent Mode 쉽게 이해하기 (0) | 2025.03.09 |
[React] useOptimistic 쉽게 이해하기 (0) | 2025.03.09 |
[React] React Conference 2024 정리 (0) | 2024.05.20 |
[React] Custom Hooks (0) | 2023.09.18 |