[트러블슈팅] 로딩 상태 관리 전략으로 사용자 경험 개선

liinyeye·2024년 11월 11일

인턴 생활

목록 보기
2/2

들어가기 전에

이번 프로젝트는 실무에서 실제로 운영될 서비스이다보니, 디자인 가이드를 지키고 사용자 입장에서 어색하지 않고 매끄럽게 로딩될 수 있도록 고민을 많이 하며 구현했다.

로딩 관련 내용은 세 가지로 나눠 이야기할 수 있을 것 같다.

1️⃣ 문제 1. 이미지 깜박임 및 layout shift 현상

🔎 문제 분석

  • 로고를 import할 때 Image 태그와 SVG 컴포넌트의 로딩 시간의 차이에 따라 느린 네트워크 환경에서는 CloseSvg 아이콘만 먼저 로드되어 보이는 현상이 생김.
  • GIF 로딩 이미지가 화면에 로딩되는 동안 layout shift 현상이 생김

💡 해결 과정

1) Image 태그와 SVG 컴포넌트의 로딩 방식의 차이 파악

Next.js의 Image 컴포넌트 사용 시

  • 외부 리소스로 취급되어 네트워크 요청 발생
  • 이미지 최적화 과정을 거침
  • 별도의 HTTP 요청으로 로드됨
  • 여러 이미지가 있을 경우 각각 개별적으로 로드

SVG 컴포넌트로 직접 import해서 사용 시

  • 빌드 시점에 코드에 인라인으로 포함됨
  • JavaScript 번들의 일부가 됨
  • 별도의 네트워크 요청 없음
  • 컴포넌트가 마운트될 때 바로 렌더링

2) SVG를 컴포넌트로 import해서 사용

  • 네트워크 요청 없이 즉시 로드
  • 모든 SVG가 동시에 표시됨
  • 깜빡임 현상 없음
  • 더 빠른 렌더링

이런 이유로 작은 크기의 SVG 파일의 경우, 컴포넌트로 직접 사용하는 것이 성능상 이점이 더 크다.

3) 페이지 마운트 시 로딩 이미지 preload

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];

🚀 결과

  • SVG를 컴포넌트로 import해서 사용하여 깜박임 현상을 없애고, 네트워크를 별도로 요청하지 않아 성능상 이득
  • 로딩 이미지 preload로 layout shift 문제 해결하여 일정한 로딩 UI 보여줌

2️⃣ 문제 2. SDK 초기화되면서 보이는 loading 이미지

👩‍🏫 요구 사항

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 초기화되는 동안 로딩 이미지를 자연스럽게 보여주고, 화면 전환 시 로딩 이미지에서 카메라로 부드럽게 화면 전환됨.


3️⃣ 문제 3. 카메라 촬영 후 사진이 캡쳐되며 share page로 넘어가는 로딩 과정

👩‍🏫 요구 사항

카메라 캡쳐 시 자연스러운 화면 전환을 위한 단계별 로딩 필요

  • 카메라 캡쳐 -> 화면 깜박임 -> 캡쳐된 화면 보여주기 -> 페이지 이동 간 로딩 UI

💡 해결 과정

❗️ 카메라 캡쳐별 화면에 맞는 단계 상태 관리 필요

  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]);

카메라 캡쳐 시 setTimeoue으로 지연을 주어 단계별 로딩 상태가 정확히 보이도록 설정

  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);
    }
  };

framer-motion 활용 단계별 컴포넌트

  {/* 화면 깜박임 효과 */}
      <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>

결과

  • 캡쳐 단계별 로딩 상태 관리 및 setTimeout으로 확실한 로딩 단계 UI 구현
  • framer-motion을 활용한 자연스러운 화면 전환
profile
웹 프론트엔드 UXUI

0개의 댓글