이번 프로젝트는 실무에서 실제로 운영될 서비스이다보니, 디자인 가이드를 지키고 사용자 입장에서 어색하지 않고 매끄럽게 로딩될 수 있도록 고민을 많이 하며 구현했다.
로딩 관련 내용은 세 가지로 나눠 이야기할 수 있을 것 같다.
Next.js의 Image 컴포넌트 사용 시
SVG 컴포넌트로 직접 import해서 사용 시
이런 이유로 작은 크기의 SVG 파일의 경우, 컴포넌트로 직접 사용하는 것이 성능상 이점이 더 크다.
Promise.allSettled를 사용해 여러 애니메이션 파일을 병렬로 불러와서, 페이지가 마운트되고 실제 애니메이션이 필요할 때 즉시 사용 가능하도록 했다.
또한, 로딩 페이지에서 컴포넌트가 로딩 상태를 별도로 관리해 컴포넌트가 준비되지 않았을 때느 마찬가지로 기본 로딩 ui를 보여줬다.
const { currentIndex, isVisible, loadingTexts } = useLoadingScreen({
duration: 3000,
});
const [loadedAnimations, setLoadedAnimations] = useState<any[]>([]);
useEffect(() => {
const preloadAnimations = async () => {
try {
const results = await Promise.allSettled([
import('lottie-light-react'),
import('../../../public/animations/LoadingGuide_1.json'),
import('../../../public/animations/LoadingGuide_2.json'),
]);
const successfulAnimations = results
.slice(1) // lottie-light-react 제외
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value.default);
setLoadedAnimations(successfulAnimations);
} catch (error) {
console.error('Failed to load animations:', error);
} finally {
onAnimationsLoaded(true);
}
};
preloadAnimations();
}, []);
if (loadedAnimations.length === 0) {
return null;
}
const animationsToShow =
loadedAnimations.length === 1 ? loadedAnimations[0] : loadedAnimations[currentIndex % loadedAnimations.length];
SDK 로딩 속도에 상관없이 2가지 로딩 gif가 각각 최소 3초씩 보이고, 이후에 SDK 카메라 화면으로 넘어가야한다.
const useLoadingScreen = ({ duration }: useLoadingScreenProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isVisible, setIsVisible] = useState(true);
const loadingTexts = [
['Preparing your shoes', 'for fitting'],
['Please scan your feet', 'with your phone'],
];
useEffect(() => {
const transitionInterval = duration;
const fadeOutDuration = 750;
// 첫 전환은 transitionInterval 후에 시작
const initialTimeout = setTimeout(() => {
setIsVisible(false);
setTimeout(() => {
setCurrentIndex(1);
setIsVisible(true);
}, fadeOutDuration);
// 이후 반복적인 전환
const intervalId = setInterval(() => {
setIsVisible(false);
setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % loadingTexts.length);
setIsVisible(true);
}, fadeOutDuration);
}, transitionInterval);
return () => clearInterval(intervalId);
}, transitionInterval);
return () => clearTimeout(initialTimeout);
}, []);
return { currentIndex, isVisible, loadingTexts };
};
export default useLoadingScreen;
const [isMinimumLoadingComplete, setIsMinimumLoadingComplete] = useState(false);
// 최소 로딩 시간 설정 (3초 * 2 = 6초)
useEffect(() => {
const timer = setTimeout(() => {
setIsMinimumLoadingComplete(true);
}, 6000);
return () => clearTimeout(timer);
}, []);
로딩페이지에서 로딩 GIF를 6초 보여주고 try-on-page로 넘어갈 때, 카메라가 초기화되는데 걸리는 시간이 추가로 있어 2-3초 정도 빈 화면을 보여주게 됨.
콘텐츠 모두 준비되는 로딩 상태를 컴포넌트 단위에서 따로 관리하고, SDK 초기화가 완료되는 동안 동일한 컴포넌트를 보여준 뒤, 초기화가 끝나면 카메라를 보여주도록 설정. 이 때 opacity 속성을 주어 자연스럽게 화면이 넘어가도록 함.
const TryOnContent = () => {
const [isContentReady, setIsContentReady] = useState(false);
//.. 다른 상태관리 로직
useEffect(() => {
if (sdkInitStatus.isInitialized && isRefMounted) {
setIsContentReady(true);
}
}, [sdkInitStatus.isInitialized, isRefMounted, isMinimumLoadingComplete]);
//... SDK 및 다른 로직
return (
//...다른 ui
{/* 로딩 오버레이 */}
<div
className={`absolute inset-0 w-full h-full transition-opacity duration-300 bg-white ${
isContentReady ? 'opacity-0 pointer-events-none' : 'opacity-100'
}`}
>
<LoadingTemplate />
</div>
</div>
//..다른 ui
);
};
export default TryOnContent;
추가로 Suspense를 사용하여 fallback에 로딩 컴포넌트를 보여주어 페이지 이동 간 로딩 상태 표시
const TryOnPage = () => {
return (
<Suspense fallback={<LoadingTemplate />}>
<TryOnContent />
</Suspense>
);
};
export default TryOnPage;
SDK 초기화되는 동안 로딩 이미지를 자연스럽게 보여주고, 화면 전환 시 로딩 이미지에서 카메라로 부드럽게 화면 전환됨.
카메라 캡쳐 시 자연스러운 화면 전환을 위한 단계별 로딩 필요
❗️ 카메라 캡쳐별 화면에 맞는 단계 상태 관리 필요
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [capturedPreview, setCapturedPreview] = useState<string | null>(null);
const [isPressed, setIsPressed] = useState(false);
const [isFlashing, setIsFlashing] = useState(false);
const [captureStep, setCaptureStep] = useState<'none' | 'flash' | 'preview' | 'loading'>('none');
useEffect(() => {
const handleRouteChangeStart = () => {
setCaptureStep('loading');
};
const handleRouteChangeComplete = () => {
if (pathname === '/share') {
setIsCaptureLoading(false);
setCapturedPreview(null);
setCaptureStep('none');
setIsFlashing(false);
setIsPressed(false);
}
};
window.addEventListener('beforeunload', handleRouteChangeStart);
window.addEventListener('load', handleRouteChangeComplete);
return () => {
window.removeEventListener('beforeunload', handleRouteChangeStart);
window.removeEventListener('load', handleRouteChangeComplete);
};
}, [pathname]);
const handleCapture = async () => {
try {
setIsPressed(true);
await new Promise((resolve) => setTimeout(resolve, 200));
setIsPressed(false);
setIsFlashing(true);
setCaptureStep('flash');
await new Promise((resolve) => setTimeout(resolve, 200));
setIsFlashing(false);
setIsCaptureLoading(true);
const base64Image = await captureImage();
if (base64Image) {
setCapturedPreview(base64Image);
setCaptureStep('preview');
setCapturedImage({ src: base64Image, timestamp: Date.now() });
await new Promise((resolve) => setTimeout(resolve, 500));
setCaptureStep('loading');
await new Promise((resolve) => setTimeout(resolve, 1000));
router.push('/share');
}
} catch (error) {
alert('캡쳐에 실패했습니다. 다시 시도해주세요.');
setIsCaptureLoading(false);
setCapturedPreview(null);
setCaptureStep('none');
setIsFlashing(false);
setIsPressed(false);
}
};
{/* 화면 깜박임 효과 */}
<AnimatePresence>
{captureStep === 'flash' && isFlashing ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 bg-white z-50"
/>
) : null}
</AnimatePresence>
{/* 이미지 프리뷰 및 로딩 */}
<AnimatePresence>
{isCaptureLoading || captureStep === 'loading' ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center z-50"
>
{captureStep === 'preview' && capturedPreview && (
<div className="relative w-full h-full">
<Image
src={capturedPreview}
alt="captured preview"
fill
className="object-cover object-center"
priority
/>
</div>
)}
{captureStep === 'loading' && isCaptureLoading && <Loading />}
</motion.div>
) : null}
</AnimatePresence>