"Life is Full of Possibilities" - Soul, 2020

우아한테크코스

Intersection Observer와 display를 활용한 랜딩 페이지 및 헤더 구현기

m2ndy 2024. 11. 16. 20:39

1차 런칭 이후 피드백 반영

  
1차 런칭 후 받은 주요 피드백은 모바일 환경을 고려해야 한다는 점이었습니다.


서비스의 주요 진입점이 카카오톡이었기 때문에 대부분의 사용자가 모바일로 접속하는 상황이었습니다. 특히, 메신저를 통해 주변 사람들에게 서비스를 홍보하는 경우가 많아 사용자들이 처음 마주하는 랜딩 페이지가 모바일 환경에 최적화될 필요가 있다는 의견이 있었습니다. 이러한 피드백을 반영해 반응형 디자인 적용을 결정했습니다.

 

저는 랜딩 페이지소개 이미지를 코드로 변환하고 반응형 작업을 맡았습니다. 또한, 헤더를 반응형으로 수정해 모바일과 데스크탑 환경 모두에서 최적의 사용자 경험을 제공하고자 했습니다.



 

반응형 랜딩 페이지 구현

기존에는 UT 피드백에 따라 랜딩 페이지를 소개 이미지로 대체했지만 화질 저하와 낮은 퀄리티의 문제점이 있었습니다. 이를 개선하기 위해 소개 이미지를 코드로 구현하면서 애니메이션과 다양한 디자인 요소를 추가했습니다.  

 

  • 결과물 미리보기

 

 
 

랜딩 페이지에서 스크롤에 따라 뷰포트에 들어오는 컴포넌트에 fade-in 애니메이션을 적용하고 싶었습니다. 다양한 방법을 시도했지만 원하는 결과를 얻지 못하던 중, 우아한테크코스 동료인 쿠키의 Intersection Observer 아이디어를 참고해 최적화된 랜딩 페이지를 구현할 수 있었습니다.

 

 

1차 구현: 독립적인 Observer 사용

초기 구현에서는 랜딩 페이지에 등장할 세 개의 컴포넌트 각각에 Intersection Observer를 개별적으로 설정했습니다. 하지만 이 방식에는 여러 문제가 있었습니다:

  • Observer 중복 생성: 각 컴포넌트에 독립적으로 Observer가 생성되어, 불필요한 리소스 낭비가 발생했습니다.
  • 관리의 어려움: 화면에 동시에 여러 컴포넌트가 보이거나, 특정 위치에서 컴포넌트가 반복적으로 렌더링 되는 버그가 발생했습니다.
// AboutPage.ts

import React, { useRef } from 'react';
import * as S from './AboutPage.styled';
import DiscussionSpace from './DiscussionSpace';
import LevelMission from './LevelMission';
import Rocket from './Rocket';
import { useScrollComponent } from '@/hooks/useScrollComponent';
import Solution from './Solution';

export default function AboutPage() {
  const componentRefs = [
    useRef<HTMLOptionElement>(null),
    useRef<HTMLOptionElement>(null),
    useRef<HTMLOptionElement>(null),
  ];

  const { isVisible: isLevelMissionVisible } = useScrollComponent(componentRefs[0], {
    threshold: 0.5,
    index: 0,
  });

  const { isVisible: isDiscussionSpaceVisible } = useScrollComponent(componentRefs[1], {
    threshold: 0.5,
    index: 1,
  });

  const { isVisible: isSolutionVisible } = useScrollComponent(componentRefs[2], {
    threshold: 0.5,
    index: 2,
  });

  const handleScrollDown = () => {
    const nextIndex = 0;
    if (nextIndex < componentRefs.length && componentRefs[nextIndex].current) {
      componentRefs[nextIndex].current.scrollIntoView({ behavior: 'smooth' });
    }
  };

  return (
    <S.Container>
      <Rocket handleScrollDown={handleScrollDown} />
      <LevelMission ref={componentRefs[0]} isVisible={isLevelMissionVisible} />
      <DiscussionSpace ref={componentRefs[1]} isVisible={isDiscussionSpaceVisible} />
      <Solution ref={componentRefs[2]} isVisible={isSolutionVisible} />
    </S.Container>
  );
}
// useScrollComponent.ts

import { useState, useEffect, type RefObject } from 'react';

interface UseScrollComponentOptions {
  threshold?: number;
  index: number;
}

export const useScrollComponent = (
  ref: RefObject<HTMLElement>,
  { threshold = 0.5, index }: UseScrollComponentOptions,
) => {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setIsVisible(true);
          } else {
            setIsVisible(false);
          }
        });
      },
      {
        threshold,
      },
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, [ref, threshold, index]);

  return { isVisible };
};

 

 

아래 이미지에서 반복 렌더링 문제를 볼 수 있습니다. 특정 스크롤 위치에서 동일한 컴포넌트가 여러 번 나타나는 현상이 있었습니다.

 

 

2차 구현: Observer 단일화 및 상태 관리

이 문제를 해결하기 위해 리팩토링을 진행했습니다. 2차 구현에서는 아래와 같은 방식으로 개선했습니다:

  1. Observer 단일화: 모든 컴포넌트가 동일한 Intersection Observer를 공유하도록 변경했습니다.
  2. 상태 기반 관리: 화면에 표시되는 컴포넌트의 인덱스를 상태로 관리하여, 항상 하나의 컴포넌트만 표시되도록 설정했습니다.

이 접근법을 통해 효율적인 리소스 관리버그 해결이라는 두 가지 목표를 달성할 수 있었습니다. 또한, Observer 단일화를 통해 코드의 가독성과 유지보수성도 크게 향상되었습니다.

 

// AboutPage.tsx

import React, { useRef } from 'react';
import * as S from './AboutPage.styled';
import DiscussionSpace from './DiscussionSpace';
import LevelMission from './LevelMission';
import Rocket from './Rocket';
import Solution from './Solution';
import { useScrollComponent } from '@/hooks/useScrollComponent';

export default function AboutPage() {
  const componentRefs = [
    useRef<HTMLOptionElement>(null),
    useRef<HTMLOptionElement>(null),
    useRef<HTMLOptionElement>(null),
  ];

  const { visibleIndex } = useScrollComponent(componentRefs, {
    threshold: 0.6,
  });

  const handleScrollDown = () => {
    const nextIndex = visibleIndex ?? 0;
    if (nextIndex < componentRefs.length && componentRefs[nextIndex].current) {
      componentRefs[nextIndex].current.scrollIntoView({ behavior: 'smooth' });
    }
  };

  return (
    <S.Container>
      <Rocket handleScrollDown={handleScrollDown} />
      <LevelMission ref={componentRefs[0]} isVisible={visibleIndex === 0} />
      <DiscussionSpace ref={componentRefs[1]} isVisible={visibleIndex === 1} />
      <Solution ref={componentRefs[2]} isVisible={visibleIndex === 2} />
    </S.Container>
  );
}
// useScrollComponent.ts

import { useState, useEffect, type RefObject } from 'react';

interface UseScrollComponentOptions {
  threshold?: number;
}

export const useScrollComponent = (
  refs: RefObject<HTMLOptionElement>[],
  { threshold = 0.5 }: UseScrollComponentOptions,
) => {
  const [visibleIndex, setVisibleIndex] = useState<number | null>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const index = refs.findIndex((ref) => ref.current === entry.target);
          if (entry.isIntersecting && index !== -1) {
            setVisibleIndex(index);
          }
        });
      },
      { threshold },
    );

    refs.forEach((ref) => {
      if (ref.current) {
        observer.observe(ref.current);
      }
    });

    return () => {
      refs.forEach((ref) => {
        if (ref.current) {
          observer.unobserve(ref.current);
        }
      });
    };
  }, [refs, threshold]);

  return { visibleIndex };
};


 
 

 

반응형 헤더 구현

 

 

헤더 작업은 다양한 레퍼런스를 참고하며 진행했습니다. 특히, 여러 사이트 중 인프런을 참고했는데, 이곳에서는 데스크탑과 모바일 환경에 따라 헤더를 두 가지 형태로 구현하고 있었습니다.

 

 
 

  • 데스크탑 전용 헤더를 데스크탑 사이즈에서 확인했을 때

 

  • 데스크탑 전용 헤더를 모바일 사이즈에서 확인했을 때

 



데스크탑 환경에서 데스크탑 전용 헤더는 display: block 상태이고, 화면을 줄여 모바일 환경이 되면 display: none이 됩니다.
 

 

 

  • 모바일 전용 헤더를 모바일 사이즈에서 확인했을 때

 

 

  • 모바일 전용 헤더를 데스크탑 사이즈에서 확인했을 때

 

 
모바일 환경에서 모바일 전용 헤더는 display: block 상태이고, 화면을 키워 데스크탑 환경이 되면 display: none이 됩니다.
 


이 점을 차용해서 구현해 보기로 했습니다.
 
 
 

결과물

PR 링크 (랜딩)

PR 링크 (헤더)

  • 데스크탑

 

 
 

  • 모바일

 

 

 

 

 

 

 

 

 

 

 

 
 
인프런처럼 화면 사이즈에 따라 display 속성이 변하는 값으로 설정했습니다. display 설정을 바꾸게 되면 reflow가 일어나 잘 바꾸지 않는 편인데, 일반적으로 처음 접근하는 기기로 화면 사이즈가 고정된다고 생각하여 display 값을 조정해도 성능에는 큰 문제가 되지 않겠다고 생각했습니다.
 
 

import useUserInfo from '@/hooks/useUserInfo';
import useLogoutMutation from '@/hooks/useLogoutMutation';
import Desktop from './Desktop';
import Mobile from './Mobile';

export default function Header() {
  const { data: userInfo } = useUserInfo();
  const { handleUserLogout } = useLogoutMutation();

  return (
    <>
      <Desktop userInfo={userInfo} handleUserLogout={handleUserLogout} />
      <Mobile userInfo={userInfo} handleUserLogout={handleUserLogout} />
    </>
  );
}

 

// Desktop header

import { useLocation } from 'react-router-dom';
import { ROUTES } from '@/constants/routes';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import type { UserInfo } from '@/types/user';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';

interface DesktopProps {
  userInfo: UserInfo | undefined;
  handleUserLogout: () => void;
}

export default function Desktop({ userInfo, handleUserLogout }: DesktopProps) {
  const { pathname } = useLocation();

  return (
    <>
      <S.Container>
        // ... 기타 코드들
      </S.Container>
      <S.Spacer />
    </>
  );
}
// Desktop header의 container 속성

export const Container = styled.nav`
  z-index: 100;
  width: 100%;
  height: 6rem;
  position: fixed;
  background: ${(props) => props.theme.colors.white};
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;

  white-space: nowrap;

  ${media.medium`
      display: none;
    `}
`;

 

 

// Mobile Header

import type { UserInfo } from '@/types/user';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import { ROUTES } from '@/constants/routes';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
import { Link, useLocation } from 'react-router-dom';
import { useState } from 'react';

interface MobileProps {
  userInfo: UserInfo | undefined;
  handleUserLogout: () => void;
}

export default function Mobile({ userInfo, handleUserLogout }: MobileProps) {
  const { pathname } = useLocation();
  const [isToggleOpen, setIsToggleOpen] = useState(false);
  const handleToggle = () => {
    setIsToggleOpen(!isToggleOpen);
  };

  return (
    <>
      <S.MobileContainer>
        // ... 기타 코드들
      </S.MobileContainer>
    </>
  );
}
// Mobile Header의 Container 속성

export const MobileContainer = styled.nav`
  display: none;

  z-index: 100;
  width: 100%;
  position: fixed;
  background: ${(props) => props.theme.colors.white};
  top: 0;
  left: 0;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;

  white-space: nowrap;
  padding: 0 2rem;

  ${media.medium`
      display: block;
    `}
`;


 
 

Mobile Header의 가로폭이 좁아져 메뉴가 겹치는 현상이 있었습니다. 따라서 햄버거바와 Dropdown을 이용하여 구현했습니다.

 

 

 

전체 코드

import type { UserInfo } from '@/types/user';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import { ROUTES } from '@/constants/routes';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
import { Link, useLocation } from 'react-router-dom';
import { useState } from 'react';

interface MobileProps {
  userInfo: UserInfo | undefined;
  handleUserLogout: () => void;
}

export default function Mobile({ userInfo, handleUserLogout }: MobileProps) {
  const { pathname } = useLocation();
  const [isToggleOpen, setIsToggleOpen] = useState(false);
  const handleToggle = () => {
    setIsToggleOpen(!isToggleOpen);
  };

  return (
    <>
      <S.MobileContainer>
        <S.MobileWrapper>
          <S.MobileLeft>
            <S.HamburgerToggleIcon onClick={handleToggle} />
          </S.MobileLeft>
          <S.MobileCenter>
            <Link to={ROUTES.main}>
              <S.LogoImage />
            </Link>
          </S.MobileCenter>

          <S.RightPart>
            {!userInfo ? (
              <a href={`${API_URL}${PATH.githubLogin}?next=${pathname}`}>
                <S.LoginButton aria-label="클릭하면 깃허브 로그인으로 이동합니다.">
                  로그인
                </S.LoginButton>
              </a>
            ) : (
              <S.LoginButton onClick={handleUserLogout}>로그아웃</S.LoginButton>
            )}
          </S.RightPart>
        </S.MobileWrapper>
        {isToggleOpen && (
          <S.ToggleMenu>
            <HeaderMenu
              name="미션"
              path={ROUTES.missionList}
              currentPath={pathname}
              handleToggle={handleToggle}
            />
            <HeaderMenu
              name="풀이"
              path={ROUTES.solutions}
              currentPath={pathname}
              handleToggle={handleToggle}
            />
            <HeaderMenu
              name="디스커션"
              path={ROUTES.discussions}
              currentPath={pathname}
              handleToggle={handleToggle}
            />
            {userInfo && (
              <HeaderMenu
                name="대시보드"
                path={ROUTES.dashboardHome}
                currentPath={pathname}
                handleToggle={handleToggle}
              />
            )}
          </S.ToggleMenu>
        )}
      </S.MobileContainer>
    </>
  );
}
import { useLocation } from 'react-router-dom';
import { ROUTES } from '@/constants/routes';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import type { UserInfo } from '@/types/user';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';

interface DesktopProps {
  userInfo: UserInfo | undefined;
  handleUserLogout: () => void;
}

export default function Desktop({ userInfo, handleUserLogout }: DesktopProps) {
  const { pathname } = useLocation();

  return (
    <>
      <S.Container>
        <S.Wrapper>
          <S.LeftPart>
            <S.LogoWrapper to={ROUTES.main}>
              <S.LogoImage />
              <S.Logo> DEVEL UP</S.Logo>
            </S.LogoWrapper>
          </S.LeftPart>
          <S.MenuWrapper>
            <HeaderMenu name="미션" path={ROUTES.missionList} currentPath={pathname} />
            <HeaderMenu name="풀이" path={ROUTES.solutions} currentPath={pathname} />
            <HeaderMenu name="디스커션" path={ROUTES.discussions} currentPath={pathname} />
          </S.MenuWrapper>
          <S.RightPart>
            {userInfo && (
              <HeaderMenu name="대시보드" path={ROUTES.dashboardHome} currentPath={pathname} />
            )}
            {!userInfo ? (
              <a href={`${API_URL}${PATH.githubLogin}?next=${pathname}`}>
                <S.LoginButton aria-label="클릭하면 깃허브 로그인으로 이동합니다.">
                  로그인
                </S.LoginButton>
              </a>
            ) : (
              <S.LoginButton onClick={handleUserLogout}>로그아웃</S.LoginButton>
            )}
          </S.RightPart>
        </S.Wrapper>
      </S.Container>
      <S.Spacer />
    </>
  );
}


 
마무리

랜딩 페이지를 구현하는 데만 약 3일을 꼬박 투자했는데, 디자인과 UI/UX에 완성도를 높일 수 있었습니다. 결과적으로 모바일 환경에서도 매끄럽게 작동하는 반응형 랜딩 페이지를 구현했고, 사용자 경험이 크게 향상되었습니다. 이번 리팩토링을 통해 랜딩 페이지의 등장 애니메이션이 더 자연스러워졌고, 유지보수와 관리가 편리해졌습니다. 이 경험을 통해 문제를 명확히 정의하고, 불필요한 중복을 제거하는 것이 얼마나 중요한지 다시금 배울 수 있었습니다.

 

 

감사합니다.