본문 바로가기
Projects,Activity/Polabo(Next)

[POLABO] 온보딩 튜토리얼 공통 컴포넌트 만들기

by 그냥하는거지뭐~ 2024. 12. 22.
목차
1. 프롤로그
2. UI 설계: div에 구멍을 뚫을 수는 없나?
3. 유연한 공통 컴포넌트를 위한 Compound component 패턴
4. 여러 페이지에서 독립적인 상태 관리하기
5. 마치면서

1. 프롤로그

1.1. Polabo에서 튜토리얼을 도입하게 된 배경

서비스를 사용할 때 제작자가 의도한 flow대로 기능을 사용하게 하려면, 사용자가 UX를 직관적으로 이해할 수 있어야 한다. 하지만 우리가 아무리 쉽게 만들었다고 해도, 사용자들이 우리 의도대로 모든 기능을 꼼꼼히 써주는 경우는 드물다.
Polabo 프로젝트에서도 유저 반응 테스트를 진행한 결과, 예상과 달리 많은 사용자가 특정 기능의 존재조차 모르는 경우가 있었다. 예를 들어, 폴라로이드 사진을 클릭하면 크게 볼 수 있는 기능이 있는데, 이를 알지 못하는 유저가 대다수였다. 또, '함께 꾸미는 폴라로이드 보드'라는 컨셉 때문에 '공유' 기능이 중요했는데, 헤더에 위치한 공유 버튼을 발견하지 못해서 클릭 수가 저조했다. 이러한 문제를 해결하기 위해 튜토리얼을 추가하기로 결정했다. 
 
사실 이전에 진행했던 TaskStock 프로젝트에서도 온보딩 튜토리얼을 만들어본 경험이 있다. 하지만 그때는 너무 정신없이 급하게 만든 탓에 제대로 된 구조나 공통 컴포넌트에 대해 고민할 여유가 없었다. 그래서 이번 기회에 여러 컴포넌트에서 간단히 재사용할 수 있는 잘 설계된 공통 컴포넌트를 만들어보고 싶었다. 개발 과정에서 내가 생각하는 좋은 공통 컴포넌트의 기준을 최대한 반영하려고 노력했다. 
 

1.2. 좋은 공통 컴포넌트란? 

1. 직관적이고 쉽게 사용할 수 있어야 한다. 
공통 컴포넌트는 라이브러리는 아니지만, 다른 UI 라이브러리를 사용할 때처럼 간단하고 직관적인 사용법을 제공해야 한다고 생각한다. 아무리 유연하더라도 설정이 복잡하거나, 러닝커브가 높으면 좋은 공통 컴포넌트라고 보기 어렵다.

ex)
- props 이름과 동작을 직관적으로 만든다
- default 값을 제공해서 필요한 경우에만 세부 조정을 하게 한다

 
2. 유연하고 다양한 상황에 대응할 수 있어야 한다. 
프로젝트를 진행하다 보면 기획이 계속 바뀌고, 그에 따라 디자인도 어떻게 바뀔지 모른다. 특정 상황에 딱 맞는 컴포넌트를 만들어놨는데, 디자인이 바뀌면 다시 만들어야 하는 상황이 생길 수도 있다. 그래서 처음부터 이런 상황을 염두해 두고 유연하게 컴포넌트를 짜야한다. 

ex)
- className이나 style을 통해 원하는 스타일을 추가할 수 있도록 허용한다
- compound component 패턴을 이용한다. 예를 들어 Tooltip이라는 컴포넌트 안에 Tooltip.Box, Tooltip.Content 등의 여러 서브 컴포넌트를 만들어서 유연하게 설계할 수 있도록 한다.

 

1.3. 기능 요구 사항 정리

튜토리얼 시작 조건

  • 비회원 또는 신규 회원에게만 튜토리얼을 노출해야 한다.
  • 사용자의 이전 튜토리얼 완료 여부를 localStorage를 통해 확인한다.
  • localStorage에 튜토리얼 완료 여부가 저장되어 있다면, 해당 기기에서 튜토리얼은 스킵한다.

튜토리얼 진행 흐름

  • 각 step마다 특정 타깃을 강조하며, 단계별로 안내 메시지를 표시한다.
  • 말풍선(tooltip)의 '다음' 버튼을 클릭하면 다음 단계로 이동한다.
  • 타깃을 클릭하면 타깃의 동작을 실행한 후 다음 단계로 넘어간다.
  • Overlay(회색 처리된 배경)를 클릭해도 다음 단계로 넘어가지 않는다.
  • 마지막 단계에서 말풍선의 '완료' 버튼을 클릭하면 튜토리얼이 종료된다.

타깃 스타일

  • 강조하는 타깃의 스타일은 dotted border 처리된 원형 또는 타깃 크기에 딱 맞는 모양이다. ('ROUND' | 'FIT') 
  • 필요에 따라 사용자 지정 스타일(targetStyleProperites)을 추가로 적용할 수 있다.

말풍선 (Tooltip) 설계

  • Tooltip은 단계별 안내 메시지를 표시하며, 디자인과 위치를 유연하게 조정 가능하다.
  • 다음 버튼을 통해 다음 단계로 이동하거나, 완료 버튼을 통해 튜토리얼을 종료할 수 있다.
  • Tooltip 내부는 Compound Component 패턴을 사용하여 Box, Content, Icon 등의 서브 컴포넌트로 구성된다.

컨텍스트 분리

  • 서로 다른 페이지에서 독립적으로 동작해야 한다.
보드 페이지 튜토리얼
꾸미기 페이지 튜토리얼

 
튜토리얼을 구현하면서 가장 기억에 남는 세 가지 이슈를 소개해보려고 한다. 


2. UI 설계: div에 구멍을 뚫을 수는 없나?

예상외로 가장 많은 시간을 투자한 부분이 UI 설계 부분이다. 머리가 터져버릴 뻔 했다. 🤯 
튜토리얼은 성격상 어디에든 추가할 수 있어야 하므로, 기존의 서비스 코드에 방해가 가면 안된다. 그러니까 튜토리얼 스타일을 구현하기 위해 기존의 서비스 코드를 변경하는 일은 없어야 한다. 

(요구사항)
- 시선을 타깃에 집중시키기 위해 타깃을 제외한 다른 부분을 dim 처리해야 한다.
- 타깃을 틀릭하면 타깃의 동작이 실행되어야 하기 때문에 타깃은 클릭 가능해야 한다.
- dim 처리된 overlay 부분을 클릭했을 때, 타깃이 아닌 다른 버튼이 눌리면 안 되기 때문에 Overlay 부분은 클릭을 막아야 한다.

 

문제 1: 타깃과 Overlay의 위치 처리

타깃을 Overlay의 자식 요소로 만들 경우, Overlay가 타깃의 레이아웃을 강제로 포함하게 된다. 이로 인해 화면 전체가 제대로 dim 처리되지 않는다. => 따라서 Overlay와 타깃은 별도의 div로 분리해야 한다. 

<>
    <Target ref={targetRef}>{children}</Target>
    <Overlay />
</>

- Overlay는 position: fixed로 화면 전체를 덮도록 처리한다.
- Overlay와 타깃을 별도의 div로 분리했기 때문에, overlay에서 타깃의 위치와 크기를 반영하려면 타깃의 정보를 직접 계산해야 한다. 
- 이를 위해 useRef와 getBoundingClientRect() 사용한다.

const targetRef = useRef<HTMLDivElement>(null)
...
const targetRect = targetRef.current.getBoundingClientRect()
...
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,

 
- 계산된 값을 반영해서 Overlay에서 타깃과 동일한 위치와 크기의 구멍을 만든다. 
- 이제 dim처리를 해야 하는데, 이걸 별도의 div로 구현하게 되면, 타깃이 원형인 경우 경계선 주변에 빈틈이 생긴다. (div는 사각형이기 때문에)
- 따라서 구멍 주변에 그림자를 생성하는 boxShadow를 사용해 dim처리를 구현해야 한다.

boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.6)',

- 그리고 타깃은 클릭이 가능해야 하므로 pointerEvents: 'none' 을 해준다. 

 

문제 2: Overlay 영역에서의 클릭 이벤트 전파 문제

이 부분까지 하면 UI는 구현됐지만, boxShadow 부분까지 클릭 이벤트가 전파되어 외부 클릭이 막히지 않는다. 그러니까 타깃은 클릭 가능, overlay는 클릭 불가능의 상황을 구현하려면 div에 구멍을 뚫어야 한다. 🤯🤯🤯 하지만 CSS로 div에 구멍을 뚫는 것은 불가능하기 때문에, 다르게 접근해야 한다. 
 
오랜 고민 끝에 같은 효과를 내는 방법을 생각해냈다. 뚫는게 아니라 덮으면 된다.
- 타깃을 제외한 화면의 네 영역(top, bottom, left, right)에 네 개의 div를 배치하여 클릭 이벤트를 차단한다. 
- 각 div는 pointer-events:auto를 설정하여 클릭 이벤트를 막는다. 

 

 
전체 코드:
https://github.com/DDD-Community/DDD-11-Sonny-Polabo-FE/blob/develop/src/components/Tutorial/Tutorial.tsx


3. 유연한 공통 컴포넌트를 위한 Compound component 패턴

문제 상황: 하나의Tooltip 컴포넌트로 모든 상황 대응하기 

Tooltip을 보면 아이콘 모양, 아이콘의 위치, 말풍선 꼬리의 위치, Box 내부의 padding이나 스타일 등등이 다 다른 것을 알 수 있다. 이 모든 조합을 하나의 컴포넌트의 props로만 처리하려고 하면 어떻게 될까?

interface TooltipProps {
  // 기본 Props
  children: ReactNode; // Tooltip에 표시할 내용
  className?: string; // Tooltip 컨테이너의 사용자 정의 스타일

  // Icon 관련 Props
  icon?: ReactNode; // Tooltip과 함께 표시할 아이콘
  iconPosition?: 'top' | 'left' | 'right'; // 아이콘의 위치 
  sendIconToBack?: boolean; // 아이콘의 z-index를 뒤로 보낼지 여부
  iconClassName?: string; // 아이콘의 추가 스타일링

  // Box (외부 레이아웃 및 꼬리) 관련 Props
  trianglePosition?: 'tl' | 'tr' | 'bl' | 'br'; // 말풍선 꼬리의 위치
  boxPadding?: string | number; // Box 내부 padding
  boxClassName?: string; // Box 스타일링
  boxBackgroundColor?: string; // Box 배경색

  // Content 관련 Props
  contentClassName?: string; // Content 영역의 스타일링
  contentTextAlign?: 'left' | 'center' | 'right'; // 텍스트 정렬 옵션

  // Next Button 관련 Props
  hasNext?: boolean; // 다음 버튼 표시 여부
  onNextClick?: () => void; // 다음 버튼 클릭 핸들러
  nextButtonText?: string; // 다음 버튼의 텍스트 
  endButtonText?: string; // 마지막 단계에서 버튼 텍스트 
  nextButtonClassName?: string; // 다음 버튼의 스타일링
}

 
이렇게나 많은 props가 필요하다. 심지어 여기서 새로운 기획이 추가되어 다른 튜토리얼이 추가된다면 해당 케이스에 맞는 스타일을 적용하기 위해 또 다른 props가 추가될 수도 있을 것이다. 이로 인해 기존의 정상적으로 구현된 컴포넌트에도 영향이 갈 수도 있고, Props가 쌓이기 때문에 유지보수하기가 점점 어려워진다. 
 

해결: Compound component 도입

Compound Component 패턴은 하나의 컴포넌트를 여러 서브 컴포넌트로 분리하고, 사용자가 원하는 대로 조합할 수 있도록 하는 패턴이다. 이 패턴의 가장 큰 장점은 사용자가 필요한 부분만 조합해서 사용할 수 있기 때문에 유연하다는 점과 가독성이 좋다는 점이다.
이 패턴을 Tooltip에 적용해 보면 Box, Icon, Content, NextBtn이 서브 컴포넌트가 될 수 있다. 
 
① 메인 컴포넌트 정의
메인 컴포넌트인 Tooltip은 서브 컴포넌트들의 컨테이너 역할을 하며, children으로 다양한 서브 컴포넌트를 받아 Tooltip의 구조를 사용자가 자유롭게 정의할 수 있다. 

const Tooltip = ({
  children,
  className = '',
}: {
  children: ReactNode
  className?: React.ComponentProps<'div'>['className']
}) => {
  return <div className={twMerge(`absolute`, className)}>{children}</div>
}

 
② 서브 컴포넌트 정의
- Box: Tooltip의 외부 레이아웃을 정의하며, 말풍선의 꼬리 위치를 조정할 수 있는 trianglePos prop을 제공
- Icon: sendToBack prop으로 아이콘의 z-index를 조정
- Content: Tooltip 내부의 텍스트나 콘텐츠를 표시
- NextBtn: 단계 이동을 위한 버튼으로, hasNext를 통해 다음 단계로 이동할지, 또는 튜토리얼을 종료할지를 결정

type TrianglePos = 'tl' | 'tr' | 'bl' | 'br'

interface BoxProps {
  children: ReactNode
  className?: React.ComponentProps<'div'>['className']
  trianglePos: TrianglePos
}

const Box = ({ children, className = '', trianglePos }: BoxProps) => {
  const TRIANGLE_POSITION: Record<TrianglePos, string> = {
    tr: 'bottom-full right-3',
    tl: 'bottom-full left-3',
    bl: 'top-full left-3 rotate-180',
    br: 'top-full right-3 rotate-180',
  };

  return (
    <div className={twMerge('absolute ...', className)}>
      <Triangle className={twMerge('absolute -z-10', TRIANGLE_POSITION[trianglePos])} />
      {children}
    </div>
  );
};

(Icon, Content, NextBtn의 코드는 생략한다.) 
 
③ Tooltip 컴포넌트 조합
Tooltip 메인 컴포넌트와 서브 컴포넌트(Box, Icon, Content, NextBtn)를 묶어서 export한다.

Tooltip.Box = Box
Tooltip.Icon = Icon
Tooltip.Content = Content
Tooltip.NextBtn = NextBtn

export default Tooltip

 
④ 사용 예시

export const Step1Tooltip = () => (
  <Tooltip className="-bottom-[130%] right-0">
    <Tooltip.Icon icon={<Step1Icon />} sendToBack className="-left-[230px]" />
    <Tooltip.Box className="w-[270px] p-4" trianglePos="tr">
      <Tooltip.Content>
        친구들에게 <span className="font-semiBold">보드를 공유</span>해보세요!
      </Tooltip.Content>
      <Tooltip.NextBtn hasNext useTutorial={useBoardTutorial} />
    </Tooltip.Box>
  </Tooltip>
)

 
하나의 Tooltip 컴포넌트에서 Props로만 처리하려고 했을 때와 비교했을 때 훨씬 구조화된 느낌이다. 
- 사용자가 필요한 서브 컴포너트만 선택적으로 사용할 수 있다
- 각 서브 컴포넌트가 독립적이기 때문에, 코드가 모듈화되어 유지보수하기가 편하다
- Props의 복잡도가 줄어들고, 사용 예시가 직관적이다. 

보드 페이지 튜토리얼 완성

전체 코드:
https://github.com/DDD-Community/DDD-11-Sonny-Polabo-FE/blob/develop/src/components/Tutorial/Tooltip.tsx


4. 여러 페이지에서 독립적인 상태 관리하기

(요구사항)
- 페이지별로 튜토리얼 진행 상태를 독립적으로 관리해야 한다. 

 
'보드 페이지'와 '꾸미기 페이지'에서 각각 튜토리얼을 진행해야 한다. 페이지별로 Context를 독립적으로 만들면 문제를 해결할 수는 있지만, 이 경우 코드의 많은 부분이 겹치게 된다. 그래서 최대한 공통 컴포넌트로 설계해 중복을 줄이려고 했다. 
하지만 공통 컴포넌트를 설계할 때 Context API를 사용해서 상태를 관리하는 경우, 아래와 같은 문제가 발생했다. 

  • 모든 상태가 중앙에서 관리되기 때문에, 여러 페이지에서 튜토리얼 상태가 독립적으로 작동하지 않고 하나의 상태를 공유하게 된다.
  • 예를 들어, A 페이지에서 튜토리얼을 진행하면 B 페이지에서도 동일한 상태(currentStep, ...)를 가지게 된다. 

따라서 페이지별로 상태 관리 구조를 분리하기 위해 동적으로 context를 생성해 공통 컴포넌트로 구현하면서도 독립성을 유지하도록 설계했다. 

const createTutorialContext = (
  hookName: string,
  providerName: string
): {
  Provider: FC<TutorialProviderProps>
  useTutorial: () => TutorialContextProps
} => {
  const TutorialContext = createContext<TutorialContextProps | undefined>(
    undefined,
  )

  const TutorialProvider: FC<TutorialProviderProps> = ({ children, storageKey }) => {
    const [run, setRun] = useState(false)
    const [currentStep, setCurrentStep] = useState(1)

    const nextStep = () => setCurrentStep((prev) => prev + 1)
    const startTutorial = () => setRun(true)
    const endTutorial = () => setRun(false)

    useEffect(() => {
      if (localStorage.getItem(storageKey) === 'true') startTutorial()
    }, [storageKey])

    const value = useMemo(() => ({
      run,
      currentStep,
      nextStep,
      startTutorial,
      endTutorial,
    }), [run, currentStep])

    return (
      <TutorialContext.Provider value={value}>
        {children}
      </TutorialContext.Provider>
    )
  }

  const useTutorial = () => {
    return useContext(TutorialContext)
  }

  return {
    Provider: TutorialProvider,
    useTutorial,
  }
}

그리고 페이지별로 Provider를 생성해서 사용한다. 

export const { Provider: BoardProvider, useTutorial: useBoardTutorial } =
  createTutorialContext('BoardTutorial', 'BoardTutorialProvider')

export const { Provider: DecorateProvider, useTutorial: useDecorateTutorial } =
  createTutorialContext('DecorateTutorial', 'DecorateTutorialProvider')

 
전체 코드:
https://github.com/DDD-Community/DDD-11-Sonny-Polabo-FE/blob/develop/src/components/Tutorial/TutorialContext.tsx

꾸미기 페이지 튜토리얼

 
이제 다른 페이지에 또다른 튜토리얼이 생기더라도 부담 없이 쉽게 추가할 수 있는 구조가 되었다!


5. 마치면서

'좋은 공통 컴포넌트' 조건에 만족하는 구조를 고민하면서 만들다 보니, 예상치 못한 예외 상황들이 계속해서 등장했다. 흐름을 설계하는 과정부터 모든 요구 사항을 충족하면서도 직관적인 구조를 만드는 일에 생각보다 많은 시간을 투자했고 많은 고민을 한 것 같다.
요구 사항을 분석하면서 다양한 패턴을 비교해보고, 가장 적절한 패턴을 도입하려고 노력했다. 결론적으로 재사용성과 유연성을 모두 갖춘 만족스러운 공통 컴포넌트가 완성된 것 같다. 
좋은 컴포넌트를 만든다는 것은 단순히 동작만 잘하는 코드를 작성하는 것이 아니라, 사용자와 개발자를 모두 배려하는 설계를 고민하는 과정임을 다시 한 번 느낄 수 있었다. 

 
 

POLABO | 함께 꾸미는 폴라로이드 보드, 폴라보

우리의 일상도 특별하게! 소중한 추억들을 공유하며 폴라로이드로 보드를 꾸며봐요.

polabo.site