야야 여러 로직들 모여있지마 !

응애·2023년 9월 14일
12

세로로 매우 긴 형태의 페이지를 작업해야 하는 일이 생겼다. 최초 작업 시에는 모든 컴포넌트를 그냥 렌더 시키게 구현했지만, 사용자가 도달하지 않을 부분까지 전부 렌더 할 필요가 있을까?라는 의문점이 들었고, React Developer Tools 내부 Profiler를 돌려가며 성능을 측정해가며 개선하기 시작했다.


화면에 보이지 않는 요소들을 굳이 렌더 해야 할까 ?

처음 가졌던 의문점을 일단 해결하기로 했다. section 별로 쪼개져 있던 컴포넌트들의 처음 형태는 대부분 비슷하게 구성이 되어있었다.

interface IProps {
  sectionItems: ISectionItem[];
  render?: boolean;
}

const ExampleSection: FC<IProps> = ({
  sectionItems,
  render = true,
}) => {
  // 데이터가 없는 경우나 어드민에서 렌더를 하지 않게 설정했을 경우
  if (!render || !sectionItems || !sectionItems.length) {
    return <></>;
  }
  
  // 실제 렌더되는 부분
  return (
    <section>
      ...
    </section>
  )
}

목표는 컴포넌트들이 뷰포트 내부에 있을 경우(혹은 뷰포트에 가까워졌을 경우)에만 렌더하고, 아닐 경우는 trigger 용으로 빈 div를 렌더하도록 하는 것이었다. React에서 쉽게 사용할 수 있는 react-intersection-observer 라이브러리 내부에 useInView 훅을 사용하여 해당 로직을 추가해 주었다.

import { useInView } from 'react-intersection-observer';

interface IProps {
  sectionItems: ISectionItem[];
  render?: boolean;
}

const ExampleSection: FC<IProps> = ({
  sectionItems,
  render = true,
}) => {
  const [ref, inView] = useInView({ triggerOnce: true });
  
  // 뷰포트 내부에 위치하지 않는 경우 빈 div 렌더 로직 추가
  if (!inView) {
    return <div ref={ref} />;
  }
  
  // 데이터가 없는 경우나 어드민에서 렌더를 하지 않게 설정했을 경우
  if (!render || !sectionItems || !sectionItems.length) {
    return <></>;
  }
  
  // 실제 렌더되는 부분
  return (
    <section>
      ...
    </section>
  )
}

1차적으로는 이렇게 해서 내가 원하던 기능을 추가할 수 있었고, 실제로 초기 렌더 속도도 작업 전, 후로 많이 개선되었다.

하지만 이게 잘 작성한 코드일까 ?

조금만 생각해 보면 이 컴포넌트가 담고 있는 역할이 더 늘어나버린 것을 알 수 있다.

  • 컨텐츠가 없을 경우에 대한 방어 코드
  • 컨텐츠에 대한 UI 렌더링
  • 뷰포트 노출 여부에 따른 렌더링

컴포넌트 내부에 이 기능을 같이 두는 게 맞을까 ?

컴포넌트와 강하게 결속시켜버려도 괜찮을까 ?

사내 어드민에서는 해당 페이지에 들어갈 정보들, 프론트엔드 측면에서 다시 본다면 각 section 컴포넌트들의 노출 순서를 제어할 수 있다. 하지만 한 가지 간과했던 부분은 각 컴포넌트들은 중복해서 노출될 수 있다는 점이다. 예를 들어 배너 영역이 최상단에도 1개, 하단에도 1개 있을 수 있다.

하지만 최상단에 위치하던(화면에 바로 노출된다), 최초에 화면에 노출되지 않던 동일한 컴포넌트를 사용하기에 같은 로직으로 렌더링 로직이 제어되고 있었다. 그에 따라서 최상단에 위치한 배너 컴포넌트에서 최초 렌더 시 뷰포트 내부에 있니?라는 로직을 거친 후에 렌더가 되기 때문에 Layout Shift가 발생했고, 나는 BannerSection에 들어가 있던 InView 로직을 삭제했다. 그에 따라 정말 당연하게도 아래와 같은 문제가 발생했다.

다른 컴포넌트들의 경우에는 원하는 대로 빈 div 태그만 노출되지만, main-banner의 경우에는 컴포넌트 내부에서 InView 로직을 삭제했기 때문에 위치에 상관없이 영향을 받아서 화면에 노출되지 않더라도 바로 컨텐츠를 렌더 해버리는 모습을 볼 수 있다. 최상단의 배너는 고정된 위치를 가지고, 밑에 있는 2개의 배너는 사내 어드민에서 노출 위치를 조정할 수 있는 컨텐츠였다. 이런 경우에는 TopBannerSection이라는 InView 로직이 없는 새로운 컴포넌트를 만들어야 할까 ?
물론 눈을 질끈 감아버리고 방치해버리는 방법도 있다.


컴포넌트 내부에서 기능을 발라내어보자 !

컴포넌트가 각자의 역할에만 집중할 수 있게 분리하자 !

children을 리턴하는 형태로 해당 기능을 분리해서, 앞으로는 children에 해당하는 컴포넌트는 InView에 관한 로직은 철저하게 분리하고, 이 부분은 기존 컴포넌트에서 덜어내자.

import { useInView } from 'react-intersection-observer';

interface IRenderInViewProps {
  children: ReactNode;
}

const RenderInView: FC<IRenderInViewProps> = ({
  children,
}) => {
  const [ref, inView] = useInView({ triggerOnce: true });

  if (!inView) {
    return <div ref={ref} style={{ height: '20px'}} />;
  }

  return <>{children}</>;
};

export default RenderInView;

이렇게 컴포넌트에서 InView와 관련된 로직은 따로 분리해서, 각 section 컴포넌트들에 반복적으로 들어갔던 부분들은 삭제하고, 다음과 같은 형태로 활용할 수 있게 만들었다.

return (
  <RenderInView>
  	<ExampleSection/>
  </RenderInView>
)

그래서 뭐가 좋은건데 ?

ExampleSection이 담당하던 여러 로직을 외부로 분리해 주면서, 해당 컴포넌트가 맡고 있던 역할을 조금 가볍게 만들어 주었다. 또한 RenderInView 컴포넌트는 children으로 어떤 컴포넌트가 들어오는지, 무슨 역할을 하고 있는지 전혀 알 필요가 없다.

즉, 컴포넌트 본인이 해야 하는 역할에만 집중해서 개발할 수 있는 환경이 구축되었다.

앞으로 비슷한 기능을 구현해야 할 때, RenderInView 컴포넌트 하위에 렌더하고 싶은 컴포넌트를 끼워주기만 하면 된다.
컴포넌트 들 간의 짧은 대화로 장점을 정리해 보자.

ExampleSection: 고마워 RenderInView ! 덕분에 나는 데이터의 유효성만 검증하고, 렌더링을 할 수 있게 되었어. 정말 고마워 ~
RenderInView: 뭐래 ㅋ 난 네가 어떤 놈인지 무슨 일을 하는지 하나도 안 궁금해 ㅋ 뷰포트에 노출되는지만 궁금함 ㅋ
ExampleSection: ;;


진짜 'RenderInView'만 씌우면 다 커버할 수 있다고 생각해 ?

뷰포트에서 없어지면 다시 빈 div만 보여줘야 한다는 요구사항이 왔다고 상상해보자. 하지만 RenderInView에는 TriggerOnce라는 옵션이 들어가 있어서 써먹지를 못할 것 같다. 이런 경우에는 어떻게 대비할 것인가?

정말 1차원 적으로 생각한다면 아래와 같이 대응할 수 있다. triggerOnce 옵션의 여부를 외부에서 주입받는 것이다.

import { useInView } from 'react-intersection-observer';

interface IRenderInViewProps {
  children: ReactNode;
  triggerOnce: boolean;
}

const RenderInView: FC<IRenderInViewProps> = ({
  children,
  triggerOnce,
}) => {
  const [ref, inView] = useInView({ triggerOnce });

  if (!inView) {
    return <div ref={ref} style={{ height: '20px' }} />;
  }

  return <>{children}</>;
};

export default RenderInView;

한눈에 봐도 좋은 코드는 아니다. 만약 렌더 하는데 조금이라도 무거운 컴포넌트가 children으로 들어온다면, rootMargin을 조정하여 컴포넌트가 미리 렌더 될 수 있는 시간을 벌어줘야 할 수도 있고, 다른 여러 가지 기능을 활용해서 확장할 수가 없다.

그렇다면 외부에서 아예 InterSectionOptions를 주입시켜버리는 것은 어떨까?

// 라이브러리에서 타입을 가져와서 활용하자
import { IntersectionOptions, useInView } from 'react-intersection-observer';

interface IRenderInViewProps {
  children: ReactNode;
  // 옵션 객체를 그대로 사용할 수 있다.
  inviewOptions: IntersectionOptions;
}

const RenderInView: FC<IRenderInViewProps> = ({
  children,
  inviewOptions,
}) => {
  // triggerOnce 옵션에서, 전체 Option을 외부에서 주입받을 수 있도록 수정
  const [ref, inView] = useInView(inviewOptions);

  if (!inView) {
    return <div ref={ref} style={{ height: '20px' }} />;
  }

  return <>{children}</>;
};

export default RenderInView;

아까보다는 조금 더 확장 가능성이 높아진 코드가 되었다. 하는 김에 가능한 경우의 수를 조금 더 추가해서 범용적으로 쓸 수 있게 해보자.

일단 내가 생각했던 확장 가능성은 크게 2가지였다.

  • 강제 렌더링 여부 (뷰포트 노출 여부에 상관없이 바로 컨텐츠를 렌더 해야 할 경우)
  • trigger의 역할을 하고 있는 div의 높이
import { IntersectionOptions, useInView } from 'react-intersection-observer';

interface IRenderInViewProps {
  children: ReactNode;
  inviewOptions: IntersectionOptions;
  forceRender: boolean;
  placeholderHeight: number;
}

const RenderInView: FC<IRenderInViewProps> = ({
  children,
  inviewOptions,
  forceRender,
  placeholderHeight,
}) => {
  const [ref, inView] = useInView(inviewOptions);
  // forceRender 조건 추가
  if (!inView && !forceRender) {
    // div의 기본 높이값 추가
    return <div ref={ref} style={{ height: `${placeholderHeight}px` }} />;
  }

  return <>{children}</>;
};

export default RenderInView;

// 사용하는 곳
return (
  <RenderInView
    placeholderHeight={20}
    forceRender={false}
    inviewOptions={{ triggerOnce: true, rootMargin: '200px' }}
  >
    // children
  <RenderInView>
)

얼추 내가 원하던 만큼 범용적으로 쓸 수 있게 된 것 같다. 이 정도로 확장하게 되면, 단순히 내가 사용하는 페이지에만 적용하는 것이 아닌 프로젝트 전체에도 사용할 수 있는 만큼 확장이 되었다. 하지만 매번 자주 사용하지 않는 props를 내려주는 것은 너무 귀찮은 일이다. defaultParameter와 객체의 spread operator을 활용해서, 기본 옵션을 지정해 주고 최소한의 props만 받도록 수정해 주자.

import { IntersectionOptions, useInView } from 'react-intersection-observer';

interface IRenderInViewProps {
  children: ReactNode;
  forceRender?: boolean;
  placeholderHeight?: number;
  inviewOptions?: IntersectionOptions;
}

// 기본 option을 여기다가 선언해준다.
const defaultInViewOptions: IntersectionOptions = {
  rootMargin: '200px',
  triggerOnce: true,
};

const RenderInView: FC<IRenderInViewProps> = ({
  children,
  inviewOptions,
  // defaultParameter를 활용하여 기본값을 설정
  forceRender = false,
  placeholderHeight = 50,
}) => {
  const [ref, inView] = useInView({
    // spread operator를 활용해서 외부에서 주입 받는 option을 덮어 쓰게 해주자.
    ...defaultInViewOptions,
    ...inviewOptions,
  });

  if (!inView && !forceRender) {
    return <div ref={ref} style={{ height: `${placeholderHeight}px` }} />;
  }

  return <>{children}</>;
};

export default RenderInView;

// 사용하는 곳
return (
  // props가 다 사라졌다 !!
  <RenderInView>
    // children
  <RenderInView>
)

RenderInView 컴포넌트가 드디어 내가 원하던 형태를 갖추게 되었다. 최초에 강하게 결합해두었던 옵션들(trigger용 div의 높이 20px, intersection options)의 기능을 그대로 수행하고 있으면서도 비슷하거나 다른 기능으로 확장까지 가능한 형태가 되었다. 또한 갈아엎어야 할 정도로 특별한 기능이 추가되지 않는 이상은 간단하게 LazyLoad하고 싶은 컴포넌트를 감싸주기만 하면 된다.

profile
2년 차 프론트엔드 응애입니다. 아무도 안보지만 글은 가끔 씁니다.

2개의 댓글

comment-user-thumbnail
2023년 9월 20일

잘 정리된 글이네요. 유용했습니다!

1개의 답글