조건부 컴포넌트 만들기

꾸개·2025년 10월 10일
0

React

목록 보기
10/10
post-thumbnail

반복되는 분기로직

프론트엔드 개발자라면, UI 분기로직이 필히 필요합니다. 상태에 따라서, 권한에 따라서, 여러가지 조건에 따라서 다르게 보여줄 UI를 지정해야 합니다.

가장 기본적으로 사용할 수 있는 방법은 단축 평가 &&를 사용하는 것입니다.

const Component = (isVisible) => {
  return {isVisible && <div>hello</div>}
}

&&의 문제는 A && B를 사용했을 때 A가 숫자일 경우 문제가 발생합니다.

const Component = (count = 0) => {
  return {count && <div>hello</div>} // '0'을 렌더링
}

단축평가로 인해 0은 falsy가 되어 0이 반환되고, React는 이를 문자열로 렌더링합니다. 만약 count가 boolean 값이라면 아무것도 렌더링 되지 않지만, 리액트는 숫자를 문자열로 렌더링 하기 때문에 0이라는 문자열이 렌더링 됩니다. 만약 숫자로 boolean을 지정하려면 정확하게 boolean으로 변환되게끔 지정해야 합니다.

const Component = (count = 0) => {
  return {count > 0 && <div>hello</div>} // false로 평가되고 아무것도 렌더링 하지 않음
}

간단하지만, 휴먼 에러를 고려한다면 유지보수성이 좋지 않을 수 있습니다. 그래서 다들 삼항연산자를 추천합니다.

const Component = (isVisible) => {
  return {isVisible ? <div>hello</div> : null}
}

삼항 연산자는 true, false 두 상태의 UI를 모두 보여줄 수도 있는 장점도 있습니다. 하지만, 코드가 길어진다면 가독성을 해친다는 단점이 있습니다.

const Component = ({ user, isLoading, hasError }) => {
  return isLoading ? (
    <Spinner />
  ) : hasError ? (
    <ErrorMessage />
  ) : user ? (
    user.isPremium ? (
      <PremiumDashboard user={user} />
    ) : (
      <FreeDashboard user={user} />
    )
  ) : (
    <LoginForm />
  )
}

중첩 삼항 연산자로 가독성이 매우 나빠진 상황입니다. 이는 early return문으로 개선할 수 있습니다.

const Component = ({ user, isLoading, hasError }) => {
  if (isLoading) return <Spinner />
  if (hasError) return <ErrorMessage />
  if (!user) return <LoginForm />
  
  return user.isPremium ? <PremiumDashboard user={user} /> : <FreeDashboard user={user} />
}

props로 조건부 컴포넌트 만들기

앞서 설명한 것처럼 많이들 사용하는 방식이지만, 이걸 컴포넌트로 만들어 좀 더 선언적으로 관리할 수 있습니다. 실제로 사내 프로젝트에서 선임 개발자분들이 만들어 놓은 <If> 컴포넌트를 사용하고 있습니다.

이번 사이드 프로젝트를 시작하면서 조건부 컴포넌트를 만들어 도입해보면 좋겠다는 생각에 컴포넌트 제작에 돌입했습니다.

아주아주 간단하게 1차원적으로 생각하여 만든 컴포넌트는 다음과 같았습니다.

type IfProps = {
  condition: boolean;
  trueComponent: React.ReactNode;
  elseComponent?: React.ReactNode;
};

export const If = ({
  condition,
  trueComponent,
  elseComponent,
}: IfProps): React.ReactNode | null => {
  if (condition) {
    return <>{trueComponent}</>;
  } else {
    return <>{elseComponent || null}</>;
  }
};

성능 문제

하지만 이 구현에는 성능 문제가 있었습니다. 자바스크립트의 함수 인자 평가 방식을 간과했기 때문입니다.

JavaScript 함수 인자의 즉시 평가

자바스크립트에서 함수의 인자는 함수 호출 전에 모두 평가됩니다.

function greet(message) {
  console.log(message);
}

greet(handleMessage()) // handleMessage()가 먼저 실행되고, 그 결과가 greet의 인자로 전달됨

JSX도 함수 호출

React의 함수형 컴포넌트를 JSX로 작성하면, 이는 함수 호출 표현식(Function Call Expression)으로 변환됩니다.

// 1. 컴포넌트 선언
function ExpensiveComponent() {
  console.log('ExpensiveComponent 실행됨!');
  return <div>Expensive</div>;
}

// 2. JSX 작성
<ExpensiveComponent />

// 3. Babel이 변환 (컴파일 타임)
React.createElement(ExpensiveComponent, null)

// 4. 실제 실행 (런타임)
ExpensiveComponent()  // ← 함수가 즉시 호출됨!
// ↓
{ type: ExpensiveComponent, props: {}, ... }  // React Element 반환

따라서 props로 <ExpensiveComponent />를 전달하면, 이는 이미 실행된 결과(React Element 객체)가 전달되는 것입니다.

<If
  condition={false}
  trueComponent={<ExpensiveComponent />}  // ← condition이 false여도 실행
  elseComponent={<CheapComponent />}      // ← 이것도 실행
/>

React.createElement()가 즉시 실행되어 넘겨지기 때문에 condition의 조건과 관계없이 trueComponent, elseComponent 둘 다 미리 생성되는 문제가 발생합니다.

조건부 컴포넌트를 만드는 이유는 조건에 따라 컴포넌트를 하나만 생성하는 것인데, 제가 만든 것은 둘을 미리 생성하고 분기에 따라 하나만 보여주는 컴포넌트였습니다.

마치 이벤트 핸들러 함수를 이벤트 props에 그대로 호출한 결과를 전달하는 것과 같은 형식입니다.

const handleClick = (message) => {
  alert(message)
}

// ❌ 마운트 시 바로 실행됨
<button onClick={handleClick('클릭')}>버튼</button>

이 예시에서는 버튼을 클릭하지 않아도 '클릭'이라는 alert 창이 뜨게 됩니다.

그 이유는 onClick props에 handleClick('클릭')처럼 함수 호출 표현식을 전달했기 때문에 즉시 실행되기 때문입니다.

개선

컴포넌트를 개선하는 방향은 다음과 같습니다. 이벤트 핸들러 함수를 콜백 함수로 넘기는 것처럼 지연 평가로 늦게 실행되게 할 수 있습니다.

const handleClick = (message) => {
  alert(message)
}

// ✅ 클릭 시에만 실행
<button onClick={() => handleClick('클릭')}>버튼</button>

이제 즉시 실행되지 않고 이벤트가 호출되면 콜백 함수가 실행됩니다.

마찬가지로 If 컴포넌트도 콜백으로 넘기면 성능을 개선할 수 있습니다.

type IfProps = {
  condition: boolean;
  trueComponent: () => React.ReactNode;
  elseComponent?: () => React.ReactNode;
};

export const If = ({
  condition,
  trueComponent,
  elseComponent,
}: IfProps): React.ReactNode | null => {
  if (condition) {
    return <>{trueComponent()}</>;
  } else {
    return <>{elseComponent?.() || null}</>;
  }
};

사용 방법

<If
  condition={isLoggedIn}
  trueComponent={() => <Dashboard />}      // ✅ 조건이 true일 때만 실행
  elseComponent={() => <LoginForm />}      // ✅ 조건이 false일 때만 실행
/>

이렇게 되면 condition의 분기를 기다렸다가 컴포넌트가 생성되기 때문에 하나의 컴포넌트만 생성할 수 있습니다.

contextAPI를 활용한 Compound Component

문제는 일일이 콜백으로 컴포넌트를 넘겨주는 것이 그리 직관적인 사용법으로 보이지 않았습니다.

더 좋은 방법이 없을까 하고 찾아보던 중 Context API를 활용하여 만드는 방법을 알게 되었습니다. 구현원리는 다음과 같습니다.

  1. Context API로 IfContext 생성
  2. Provider의 value를 condition으로 주입
  3. children으로 UI 컴포넌트 전달
  4. Compound Component로 True, False 컴포넌트를 If 컴포넌트에 종속
import { createContext, useContext } from 'react';

type IfProps = {
  condition: boolean;
  children: React.ReactNode;
};

const IfContext = createContext(true);

export const If = ({ condition, children }: IfProps) => (
  <IfContext.Provider value={condition}>{children}</IfContext.Provider>
);

const True = ({ children }: { children: React.ReactNode }) => {
  const condition = useContext(IfContext);
  return condition ? children : null;
};

const False = ({ children }: { children: React.ReactNode }) => {
  const condition = useContext(IfContext);
  return condition ? null : children;
};

If.True = True;
If.False = False;


// 사용 방법

<If condition={isLoggedIn}>
  <If.True>
    <Dashboard />
  </If.True>
  <If.False>
    <LoginForm />
  </If.False>
</If>

JSX 문법처럼 태그로 활용하여 가독성이 높고, True/False로 분기를 명시해줘서 의도가 명확하여 처음 구현한 것보다 만족스러운 구현이 되었습니다.

React 19의 Activity 컴포넌트

React 19.2 버전에는 Activity 컴포넌트가 새롭게 추가되었습니다.

분기 상태를 만들고 UI 일부를 감싸서 표시 상태를 관리할 수 있습니다.

import {unstable_Activity as Activity} from 'react';

<Activity mode={isVisible ? 'visible' : 'hidden'}>
  <Page />
</Activity>

공식 문서에 따르면

"hidden" 상태일 때 Activity의 children은 페이지에 표시되지 않습니다.
새로운 Activity가 "hidden" 상태로 마운트되면 페이지의 표시되는 콘텐츠를 차단하지 않으면서 낮은 우선순위로 콘텐츠를 사전 렌더링하지만, Effect를 생성하여 마운트하지는 않습니다.
"visible" Activity가 "hidden"으로 전환되면 모든 Effect를 제거하여 개념적으로는 마운트 해제되지만, 상태는 저장됩니다.

즉, 완전히 언마운트하지 않고도 비활성화된 상태로 전환할 수 있게 해주는 개념입니다. DOM과 상태는 그대로 유지하되, 그 안의 Effect와 이벤트 핸들러는 일시적으로 중단됩니다.

예를 들어, Tab UI를 Activity로 구현하면 다음과 같습니다.

import { Suspense, useState, unstable_Activity as Activity } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";

export default function TabContainer() {
  const [tab, setTab] = useState("about");
  return (
    <Suspense fallback={<h1>🌀 Loading...</h1>}>
      <TabButton isActive={tab === "about"} action={() => setTab("about")}>
        About
      </TabButton>
      <TabButton isActive={tab === "posts"} action={() => setTab("posts")}>
        Posts
      </TabButton>
      <TabButton isActive={tab === "contact"} action={() => setTab("contact")}>
        Contact
      </TabButton>
      <hr />
      <Activity mode={tab === "about" ? "visible" : "hidden"}>
        <AboutTab />
      </Activity>
      <Activity mode={tab === "posts" ? "visible" : "hidden"}>
        <PostsTab />
      </Activity>
      <Activity mode={tab === "contact" ? "visible" : "hidden"}>
        <ContactTab />
      </Activity>
    </Suspense>
  );
}

이렇게 구현하면 숨겨진 Activity로 비활성 탭을 사전 렌더링하여 다른 탭의 지연 시간을 줄일 수 있습니다.

profile
내 꿈은 프론트 왕

0개의 댓글