온보딩 페이지 구현 스토리…..

정혜인·2024년 12월 3일
0

저희 프로젝트 특성상 처음 접속하는 사용자가 사용법을 익히기에 어려움이 있을 것이라 판단했고, 온보딩 페이지가 필수적으로 들어가야겠다고 생각하게 되었습니다....... 하지만 시간이 너무 없었기 때문에..... 가장 간단하게 (하지만 라이브러리를 사용하진 않고.....) 구현하기 위해 고민했고, 빠르게 구현할 수 있었습니다.

이번 프로젝트에서는 로컬 스토리지(Local Storage)를 활용해 한 번 온보딩을 완료한 사용자에게는 다시 온보딩을 보여주지 않는 기능과, 가장 간단하고 빠르게 구현하면서도 반응형을 고려한 UI로 구현하는 것을 중요하게 생각했습니다.

그래서 이번 포스팅에서는 제가 온보딩 페이지를 구성하면서 경험했던 내용을 작성해보려고 합니다!!


🎯 주요 고민 (?)

주요하게 구현해야 했던, 생각해야 했던 요소는 아래 4가지 정도였습니다.

  1. 온보딩 슬라이드 구현
    • 사용자가 이전/다음 버튼을 넘기면 튜토리얼이 진행되도록 설계
  2. 한 번만 실행되도록 제어
    • 로컬 스토리지를 활용해 이미 온보딩을 완료한 사용자는 다시 보지 않도록 설정
  3. 반응형 UI
    • 모든 모바일 환경에서 자연스럽게 동작하도록 반응형 디자인 구현
  4. 간단하고 빠른 구현
    • 가장 간단한 방법으로 빠르게 구현

📂 주요 파일 구조

저희 프로젝트에는 온보딩 페이지보다 훨씬 중요한 기능 개선이나 UI 개선이 많았기 때문에, 온보딩 페이지는 최대한 간단하고 빠르게 구현해야 했습니다……..

그래서 최대한 이미 저희가 구현해둔 사이트의 캡처 이미지를 사용하려고 했고, 그래서 이런 식으로 데이터를 따로 저장할 수 있도록 파일 구조를 설계하였습니다.

src/
├── components/
│   └── Onboarding.tsx     // 온보딩 UI를 담은 컴포넌트
├── lib/
│   └── data/
│       └── onboardingData.ts // 온보딩 슬라이드 데이터
└── App.tsx                // 온보딩 컴포넌트를 호출하는 루트 컴포넌트

📜 구현 내용

1. Onboarding.tsx 컴포넌트 구성

🔍 슬라이드 관리 및 상태

먼저 useState를 사용해 현재 슬라이드(currentSlide)를 관리하고, '다음' 버튼을 누르면 슬라이드가 이동하도록 구현했습니다.

그리고 마지막 슬라이드에서는 자동으로 온보딩이 종료됩니다.

const [currentSlide, setCurrentSlide] = useState(0);

const handleNext = () => {
  if (currentSlide < onboardingData.length - 1) {
    setCurrentSlide(currentSlide + 1);
  } else {
    onComplete(); // 온보딩 종료
  }
};

🔍 종료 버튼 및 로컬 스토리지

onComplete 함수는 온보딩을 종료하고 로컬 스토리지에 onboardingCompleted 플래그를 저장합니다.

이를 통해 사용자가 다시 방문했을 때 온보딩을 생략하도록 했습니다.

(사실 실제 코드에서는 localStorage에 저장하는 util 함수를 만들어두었기 때문에 조금 다르지만, 이런 식으로 localStorage에 저장했다 정도만 봐주시면 될 것 같습니다!!)

const onComplete = () => {
  localStorage.setItem('onboardingCompleted', 'true');
  // 이후 메인 화면으로 이동하거나 온보딩 종료 처리
};

🔍 Tailwind CSS로 반응형 UI 구현

온보딩 페이지는 이미지설명 텍스트로 구성되어 있고, 이를 Tailwind CSS로 배치했습니다.

(위에서 언급했듯 시간이 없었기에 최대한 간단하게 구현하고자 했고, 이미지를 활용하는 방식이 가장 간단하다고 판단했습니다.)

그리고 이미지에는 absoluteobject-contain을 사용해 크기와 위치를 동적으로 조정해주었습니다.

(이건 아마 아래에 완성된 결과 화면을 보시면 이해되실 것 같습니다!)

<img
  src={`/assets/images/onboarding/slide${onboardingData[currentSlide].id}.png`}
  alt={`Slide ${currentSlide + 1}`}
  className="absolute top-10 mx-auto h-[75vh] object-contain"
/>

2. App.tsx에서 온보딩 호출

🔍 로컬 스토리지 확인

App.tsx에서는 로컬 스토리지의 onboardingCompleted 값을 확인하여 온보딩 여부를 결정했습니다.

import React, { useState, useEffect } from 'react';
import { Onboarding } from './components/Onboarding';

const App = () => {
  const [showOnboarding, setShowOnboarding] = useState(false);

  useEffect(() => {
    const completed = localStorage.getItem('onboardingCompleted');
    if (!completed) {
      setShowOnboarding(true);
    }
  }, []);

  return (
    <div>
      {showOnboarding ? (
        <Onboarding onComplete={() => setShowOnboarding(false)} />
      ) : (
        <div>메인 콘텐츠 표시</div>
      )}
    </div>
  );
};

export default App;

3. 데이터 관리: onboardingData.ts

온보딩 슬라이드에 표시할 내용을 별도의 데이터 파일로 관리했습니다. 이를 통해 컴포넌트와 데이터를 분리하고 유지보수성을 높였습니다.

export const onboardingData = [
  {
    id: 1,
    content: '저희 ‘선따라 길따라’ 서비스가 처음이시라고요? \n제가 사용법을 알려드릴게요!',
  },
  {
    id: 2,
    content: '채널을 생성하고 싶으시다면, \n회원가입 후 로그인을 해주세요!',
  },
  {
    id: 3,
    content:
      '로그인 후 본인이 생성해둔 채널들을 확인할 수 있습니다! \n공유하기를 눌러 게스트 별로 링크를 확인할 수도 있어요!',
  },
  ...
];

🎨 UI 및 사용자 경험

  • 슬라이드 네비게이션
    하단에 점(dot) 형태의 슬라이드 네비게이션을 추가해 현재 슬라이드 위치를 시각적으로 표시했습니다.
<div className="flex space-x-2">
  {onboardingData.map((slide, index) => (
    <div
      key={slide.id}
      className={`h-1 w-1 rounded-full ${
        index === currentSlide ? 'bg-blueGray-200' : 'bg-gray-300'
      }`}
    />
  ))}
</div>
  • 종료 버튼
    우측 상단에 '튜토리얼 끝내기' 버튼을 배치하여 사용자가 언제든 온보딩을 빠르게 종료할 수 있도록 했습니다.

🔧 모바일 스크롤 문제 해결: height 설정

그런데 배포 후 온보딩 페이지를 모바일 환경에서 실행하면서 예상치 못한 문제가 발생했습니다.

특정 기기에서 height 값이 window.innerHeight와 일치하지 않아 불필요한 스크롤이 발생하는 현상이었습니다.

이는 모바일 브라우저가 뷰포트를 계산하는 방식이 window.innerHeight와 일치하지 않을 때 발생하는 문제로, 이를 해결하기 위해 CSSJavaScript를 결합한 방법을 적용했습니다.

📌 발생한 문제

모바일 브라우저는 주소 표시줄과 같은 UI 요소로 인해 뷰포트의 높이가 동적으로 변경될 수 있습니다.

특히, vh 단위는 뷰포트 높이의 1%를 의미하지만, 모바일에서 브라우저 UI에 의해 잘못 계산될 가능성이 있습니다.

그 결과 의도하지 않은 스크롤이 발생했습니다.

📌 해결 방법

useEffect를 사용해 동적으로 높이를 계산하고 이를 CSS 변수로 설정하여 모든 화면에서 동일한 동작을 보장하도록 구현했습니다.

💻 코드 적용

useEffect(() => {
  const setVh = () => {
    // 실제 높이를 1vh 단위로 변환
    const vh = window.innerHeight * 0.01;
    document.documentElement.style.setProperty('--vh', `${vh}px`);
  };
  setVh();

  // 창 크기가 변경될 때마다 높이를 다시 계산
  window.addEventListener('resize', setVh);
  return () => {
    window.removeEventListener('resize', setVh);
  };
}, []);

이 코드를 통해 --vh라는 CSS 변수를 생성하고, 이를 스타일에서 사용하여 정확한 높이를 보장했습니다.

💡 적용된 CSS

html, body {
  height: calc(var(--vh, 1vh) * 100); /* --vh를 기준으로 전체 높이 계산 */
  overflow: hidden; /* 스크롤 제거 */
}

💻 컴포넌트에서 height 설정

Onboarding.tsx 컴포넌트의 최상단 컨테이너에 style 속성을 사용하여 높이를 설정했습니다.

<div
  className="relative flex w-full flex-col items-center justify-center overflow-hidden bg-gray-100"
  style={{ height: `calc(var(--vh, 1vh) * 100)` }}
>
  {/* 온보딩 콘텐츠 */}
</div>

🛠 최종 결과

이 방법을 통해서 아래와 같이 모바일 환경에서도 스크롤 없이 온보딩 페이지가 스크롤 없이 표시될 수 있었습니다. (배터리는……무시해주세요……………..ㅋㅋㅋㅋㅋㅋㅋ큐ㅠㅠㅠㅠㅠ)

이번 구현에서 반응형 디자인, 로컬 스토리지 활용, 데이터와 UI의 분리를 통해 유지보수성과 사용자 경험을 모두 고려한 설계를 할 수 있었습니다.


[Onboarding.tsx 파일 전체 코드]

import { useEffect, useState } from 'react';
import { onboardingData } from '@/lib/data/onboardingData.ts';
import { MdClear } from 'react-icons/md';

interface IOnboardingProps {
  onComplete: () => void;
}

export const Onboarding = ({ onComplete }: IOnboardingProps) => {
  const [currentSlide, setCurrentSlide] = useState(0);

  useEffect(() => {
    const setVh = () => {
      const vh = window.innerHeight * 0.01;
      document.documentElement.style.setProperty('--vh', `${vh}px`);
    };
    setVh();

    window.addEventListener('resize', setVh);
    return () => {
      window.removeEventListener('resize', setVh);
    };
  }, []);

  const handleNext = () => {
    if (currentSlide < onboardingData.length - 1) {
      setCurrentSlide(currentSlide + 1);
    } else {
      onComplete();
    }
  };

  const handlePrev = () => {
    if (currentSlide > 0) {
      setCurrentSlide(currentSlide - 1);
    }
  };

  return (
    <div
      className="relative flex w-full flex-col items-center justify-center overflow-hidden bg-gray-100"
      style={{ height: window.innerHeight }}
    >
      <div className="absolute right-3 top-3 z-[6000] flex items-center gap-2">
        <div className="text-sm text-gray-200">튜토리얼 끝내기</div>
        <button
          onClick={onComplete}
          className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-gray-200"
        >
          <MdClear size={18} color="grayscale-850" />
        </button>
      </div>

      <div className="relative flex h-screen w-full items-center justify-center">
        <img
          src={`/assets/images/onboarding/slide${onboardingData[currentSlide].id}.png`}
          alt={`Slide ${currentSlide + 1}`}
          className="absolute top-10 mx-auto h-[75vh] object-contain"
        />
        <div className="absolute inset-0 bg-black bg-opacity-30" />
      </div>

      <div className="absolute bottom-2 flex w-[95%] flex-col">
        <div className="flex w-[100%] items-center justify-center text-white">
          <img
            src="/assets/images/onboarding/character.png"
            alt="캐릭터"
            className="max-h-16 w-[15%] object-contain"
          />
          <div
            className="bg-blueGray-200 m-2 flex h-20 w-[85%] items-center justify-center whitespace-pre rounded-lg bg-opacity-[0.5] text-center text-sm leading-relaxed"
            style={{ padding: '1rem 1rem' }}
          >
            {onboardingData[currentSlide].content}
          </div>
        </div>

        <div className="flex items-center justify-between p-4">
          <button
            onClick={handlePrev}
            disabled={currentSlide === 0}
            className="rounded bg-gray-300 px-4 py-2 text-gray-700 disabled:opacity-50"
          >
            이전
          </button>
          <div className="flex space-x-2">
            {onboardingData.map((slide, index) => (
              <div
                key={slide.id}
                className={`h-1 w-1 rounded-full ${
                  index === currentSlide ? 'bg-blueGray-200' : 'bg-gray-300'
                }`}
              />
            ))}
          </div>
          <button onClick={handleNext} className="bg-blueGray-200 rounded px-4 py-2 text-white">
            {currentSlide === onboardingData.length - 1 ? '시작' : '다음'}
          </button>
        </div>
      </div>
    </div>
  );
};

0개의 댓글