
새로운 프로젝트에서 Emotion을 사용해서 공동 컴포넌트를 만들고 있었어요.
어떻게 작성해야 가장 깔끔하고 확장성이 좋을지 고민이 생기더라고요.
고민하던 중 유튜브에서 SOLD 원칙에 대한 영상을 우연히 보다,
스타일 상속을 통한 공동 컴포넌트 개발이 확장성 면에서 좋겠다는 생각이 들었습니다.
그렇게 모든 공동 컴포넌트를 상속을 통한 확장으로 변경하며, 겪은 예상치 못한 버그를 경험했습니다.
이 버그를 통해 1.상속(Extend)과 2.하나의 Styled Component 안에서 props로 분기의 차이점에 대해 깨닫게 되어 글을 쓰게 되었습니다.
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
};
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를 빨간색 변화시킵니다.
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,
};
자 해당 로직을 생각하면, 리렌더링이 일어난다고 합시다.
어떤 문제가 있을까요?
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도 그대로 남아 있고, 포커스 역시 유지되는 것입니다.
아래와 같이 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가 변경되는지”를 파악하는 것입니다.
이 글이 Emotion을 이용해 공동 컴포넌트를 설계할 때 도움이 되었으면 좋겠습니다.
감사합니다!