Emotion - 분기 처리 방식 / 상속 vs Styled 내 분기

Maria Kim·2025년 2월 24일
0
post-thumbnail

새로운 프로젝트에서 Emotion을 사용해서 공동 컴포넌트를 만들고 있었어요.
어떻게 작성해야 가장 깔끔하고 확장성이 좋을지 고민이 생기더라고요.

고민하던 중 유튜브에서 SOLD 원칙에 대한 영상을 우연히 보다,
스타일 상속을 통한 공동 컴포넌트 개발이 확장성 면에서 좋겠다는 생각이 들었습니다.

그렇게 모든 공동 컴포넌트를 상속을 통한 확장으로 변경하며, 겪은 예상치 못한 버그를 경험했습니다.
이 버그를 통해 1.상속(Extend)과 2.하나의 Styled Component 안에서 props로 분기의 차이점에 대해 깨닫게 되어 글을 쓰게 되었습니다.

두 가지 분기 처리 방식

  1. 상속(Extend)을 이용해 분기
  • 기본(Base) Styled Component를 만들고,
  • 필요한 variant 별로 이 컴포넌트를 상속해서 각각 다른 스타일을 적용합니다.
  • variant가 많아져도, 컴포넌트를 추가하는 형태로 확장하기 쉽습니다.
  1. 하나의 Styled Component 안에서 분기 처리
  • variant 같은 값을 받아서, 하나의 Styled Component 내부에서 switch나 삼항 연산자 등을 사용해 스타일을 다르게 적용합니다.
  • 동적으로 자주 변하는 props에도 즉각 대응하기 좋습니다.

간단 예시 코드 비교

  1. 상속으로 처리
function Button({ variant = "primary", disabled, children }: ButtonProps) {
  const Component = ButtonComponent[variant];
  return <Component disabled={disabled}>{children}</Component>;
}

const StyledButtonBase = styled.button`
  /* 공통 스타일 */
`;

const PrimaryButton = styled(StyledButtonBase)`
  background-color: black;
  color: white;
`;

const DangerButton = styled(StyledButtonBase)`
  background-color: red;
  color: white;
`;


const ButtonComponent = {
  primary: PrimaryButton,
  danger: DangerButton
};
  • variant가 추가될 때마다, styled(StyledButtonBase)를 확장해서 새로운 컴포넌트(예: DangerButton)를 만들 수 있습니다.
  1. styled component 내에서 분기 처리
function Button({ variant = "primary", disabled, children }: ButtonProps) {
  return (
    <StyledButton variant={variant} disabled={disabled}>
      {children}
    </StyledButton>
  );
}

const StyledButton = styled.button<ButtonProps>`
  /* 공통 스타일 */
  background-color: ${(props) => getBackgroundColor(props)};
  color: ${(props) => getColor(props)};
`;

function getBackgroundColor({ variant, disabled, theme }: ButtonProps & { theme: Theme }) {
  switch (variant) {
    case "primary":
      return blue;
    case "danger":
      return red;
    default:
      return gray;
  }
}

function getColor({ variant, disabled, theme }: ButtonProps & { theme: Theme }) {
  switch (variant) {
    case "primary":
      return white;
    case "danger":
      return white;
    default:
      return black;
  }
}

*좋은 예시는 아닙니다. 이 버튼 컴포넌트가 마운트 된 후 상태의 종류에 따라 버튼 색이 변경될 수 있습니다. 이 경우보다, Background 버튼과 Border 버튼 같은 디자인 종류에 의한 차이가 있을 때 Styled 컴포넌트 상속을 사용하면 좋습니다.

문제 상황

InputContainer 은 Input을 감싸는 컴포넌트입니다.
input의 값이 변경되면(onChange), 유효성 검사를 합니다.
유효성 검사를 한 후, 에러가 발생되면 variant='error'를 전달해서 border를 빨간색 변화시킵니다.

  • 상속으로 처리하는 InputContainer
function InputContainer({ variant = "default", children }: InputContainerProps) {
  const Component = InputContainerComponent[variant];
  return <Component>{children}</Component>;
}

const BaseInputContainer = styled.div`
  border: 1px solid black;
`;

const ErrorInputContainer = styled(BaseInputContainer)`
  border-color: red;
`;

const InputContainerComponent = {
  default: BaseInputContainer,
  error: ErrorInputContainer,
};

자 해당 로직을 생각하면, 리렌더링이 일어난다고 합시다.
어떤 문제가 있을까요?

  • props 분기로 처리하는 InputContainer
function InputContainer({ variant = "default", children }: InputContainerProps) {
  return (
    <StyledInputContainer variant={variant}>
      {children}
    </StyledInputContainer>
  );
}

const StyledInputContainer = styled.div<{ variant: "default" | "error" }>`
  border: 1px solid
    ${({ theme, variant }) =>
      variant === "error" ? theme.colors.variants.negative : theme.colors.neutral.gray300};
  /* ... */
`;

이 컴포넌트의 리렌더링과 어떤 차이점이 있을까요?

찾으셨나요???

직면한 버그

제가 경험한 버그는 border가 변경될 때마다 input의 포커스가 사라지는 문제였습니다.

왜 이런 일이 발생했을까요?

그 이유는 상속을 통한 분기 처리 방식이 결국 새로운 컴포넌트를 생성하는 방식이기 때문입니다.
즉, variant 값이 변경될 때마다 React는 기존 컴포넌트를 제거하고, 새로운 컴포넌트를 마운트하게 됩니다.
이 과정에서 이전에 존재하던 input 요소가 사라지고 새롭게 생성되므로, React는 기존 input을 인식하지 못하고, 포커스도 함께 사라지게 되는 것이죠.

그렇다면 props를 활용한 분기 처리는 어떨까요?

props를 이용한 방식에서는 하나의 동일한 컴포넌트 내에서 class(className)가 추가되거나 삭제되며 스타일만 변경됩니다.
즉, 컴포넌트 자체는 그대로 유지되기 때문에 내부 input도 그대로 남아 있고, 포커스 역시 유지되는 것입니다.

요약

상속으로 처리하는 InputContainer

  • variant가 변경되면, “다른 컴포넌트”를 렌더링합니다.
  • 구조가 단순하다면 React가 “같은 DOM”으로 인식해 줄 수도 있지만, 상황에 따라 완전히 다른 DOM으로 간주해 재마운트될 수 있습니다.
  • 만약 그 안의 input이 포커스 상태였으면, 재마운트로 인해 포커스가 풀릴 수 있다는 문제점이 생기죠.

props 분기로 처리하는 InputContainer

  • variant가 바뀌면, 이미 마운트 된 컴포넌트의 스타일만 변경됩니다.
  • 따라서 내부에 포커스 된 input이 있어도, variant 변경 시 포커스가 유지됩니다.

결론 & 추천 팁

언제 상속을 쓰면 좋을까?

  • 같은 뷰 상에서 variant가 변화하지 않을 것으로 예상될 때
  • 예: Button의 Border 또는 Background 같은 버튼의 종류. (상태에 따라 달리지는 danger, success 같은 경우는 class 분기 처리)
  • 상속 방식을 쓰면 Open-Closed Principle(개방-폐쇄 원칙)에 맞춰 확장하기가 깔끔해집니다.

언제 props 분기 처리를 쓰면 좋을까?

  • 사용자와의 상호작용이 많아서, variant나 disabled 등이 빈번히 바뀌는 경우
  • 예: Input 같은 폼 요소처럼, 오류 상태(error)나 기타 상태가 실시간으로 변할 때
  • 마운트/언마운트가 일어나지 않으므로, 포커스 같은 상태도 안정적으로 유지됩니다

Emotion의 경우 class만 따로 정의해서 Open-Closed Principle(개방-폐쇄 원칙)에 맞춰 확장 가능합니다.

아래와 같이 emotion의 util인 css로 작성하면,
class만 변경하면서 Open-Closed Principle(개방-폐쇄 원칙)을 적용할 수 있습니다.

import { css } from "@emotion/react";
import styled from "@emotion/styled";

import theme from "@/styles/theme";

export type InputVariant = "default" | "error";

interface InputContainerProps {
  children: React.ReactNode;
  className?: string;
  variant?: InputVariant;
}

function InputContainer({
  children,
  className,
  variant = "default",
}: InputContainerProps) {
  return (
    <StyledInputContainer variant={variant} className={className}>
      {children}
    </StyledInputContainer>
  );
}

export default InputContainer;

interface StyledInputContainerProps {
  variant: InputVariant;
}

const StyledInputContainer = styled.div<StyledInputContainerProps>`
  display: flex;
  padding: 11px 18px;
  justify-content: space-between;
  border-radius: 5px;
  background: ${({ theme }) => theme.colors.neutral.white};
  ${({ variant }) =>
    CustomInputContainer[variant] ?? CustomInputContainer.default};
`;

const defaultInputContainer = css`
  border: 1px solid ${theme.colors.neutral.gray100};
`;

const errorInputContainer = css`
  border: 1px solid ${theme.colors.variants.negative};
`;

const CustomInputContainer: Record<
  NonNullable<InputContainerProps["variant"]>,
  typeof defaultInputContainer
> = {
  default: defaultInputContainer,
  error: errorInputContainer,
};

최종 정리

  • 실시간으로 UI가 자주 바뀌는 컴포넌트: 하나의 컴포넌트 안에서 분기 처리
  • 마운트 후 동적 변화는 없고, 여러 타입으로 확장할 필요가 있는 컴포넌트: 상속 방식
  • 필요하다면 두 방식을 섞어서 사용할 수 있습니다. 예를 들어, Button처럼 큰 범주(디자인의 차이인 Border, Background 등)는 상속으로 구분하고, 세부적인 상태 변화(error, success 등)는 props로 처리하는 식이죠.

핵심은 “언제 UI가 변경되는지”를 파악하는 것입니다.

이 글이 Emotion을 이용해 공동 컴포넌트를 설계할 때 도움이 되었으면 좋겠습니다.
감사합니다!

profile
Developer, who has business in mind.

0개의 댓글