공통 컴포넌트를 만들 때 가장 중요한 요소

김채은·2023년 11월 15일
25
post-thumbnail

들어가며

당근에서 윈터 테크 인턴십 모집을 시작했다. 내가 지원하는 그룹 플랫폼 프론트엔드 포지션에는 두 가지 사전 질문이 있었다. 그 중 첫 번째는 공통 컴포넌트에 관한 질문이었다. 최근 비즈니스 로직과 UI 분리, 공통 컴포넌트 개발에 관심을 가지고 공부하는 중이어서, 질문에 답변하기 전 조금 더 깊이 생각해보기로 했다.

공통 컴포넌트를 만들 때 가장 중요한 요소 2개를 선정하고 이유와 방법을 함께 설명해주세요.

나는 고민 끝에 가장 중요한 요소 두 가지를 다음과 같이 선정했다.

공통 컴포넌트는
1. 도메인 컨텍스트로부터 분리돼야 한다.
2. 디자인 변경 사항에 유연하게 대응해야 한다.

지금부터 그렇게 생각한 이유와, 구현 방법에 대해 이야기 해보겠다.

도메인 컨텍스트로부터 분리돼야 한다.

컴포넌트가 특정 도메인과 결합돼있으면 다른 도메인에서 재사용하기 어려워지고, 비즈니스 요구사항이 변경됐을 때 다른 도메인 로직에 영향을 줄 수 있다.

예를 들어 '비밀번호가 8자 이상 20자 이하이고, 영소문자와 숫자, 특수문자를 포함'해야 하는 input 요소는 아이디나 닉네임을 입력할 때 똑같은 디자인이 렌더링 된다고 해도 재사용할 수 없다.

'비밀번호가 8자 이상 20자 이하이고, 영소문자와 숫자, 특수문자를 포함'이라는 비즈니스 로직을 객체로 분리하고 input 컴포넌트의 외부에서 이를 주입해준다면? 똑같이 아이디 또는 닉네임의 비즈니스 로직을 주입함으로써 재사용할 수 있다.

React Hook Form를 사용한 form을 공통 컴포넌트로

작년에 만들었던 방명록 서비스인 Text Me에서 비슷한 구조의 form 로직이 세 페이지에서 사용되는데 재사용하고 있지 않았기에 이것을 리팩터링 해보려고 한다. 당시 React Hook Form 라이브러리를 사용하는 데에만 집중하느라, 공통 컴포넌트로 만들려는 시도는 하지 않았던 것 같다.

컴포넌트 분리를 아예 안한 페이지이기 때문에 주요 로직만 보이도록 간단히 요약해보았다.

  • /pages/singin.tsx
type SignInForm = {
  email: string;
  password: string;
};

function SignIn() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignInForm>();


  const signIn = async (data: SignInForm) => {
    // ...
  };

  return (
    <div>
      <form onSubmit={handleSubmit(signIn)}>
          <div>로그인</div>
          <label>
            <input
              {...register("email", {
                required: "이메일을 입력해주세요.",
                pattern: {
                  value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
                  message: "올바른 이메일 형식이 아닙니다.",
                },
              })}
              placeholder="이메일을 입력해주세요."
            />
            {errors.email && <em>{errors.email.message}</em>}
          </label>
          <label>
            <input
              type="password"
              {...register("password", {
                required: "비밀번호를 입력해주세요.",
                minLength: {
                  value: 8,
                  message: "최소 8자 이상의 비밀번호를 입력해주세요.",
                },
                maxLength: {
                  value: 64,
                  message: "비밀번호는 64자를 초과하면 안됩니다.",
                },
                pattern: {
                  value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
                  message:
                    "영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요",
                },
              })}
              placeholder="비밀번호를 입력해주세요."
            />
            {errors.password && <em>{errors.password.message}</em>}
          </label>
          <button type="submit">로그인</button>
      </form>
    </div>
  );
}

export default SignIn;

input 유효성 확인 로직 분리

UI 코드 안에 input의 프로퍼티와 유효성을 체크하는 로직이 잔뜩 뿌려져있다. 우선 변경 가능성이 가장 높은 도메인 맥락인 유효성 확인 로직을 객체로 분리해주었다.

const SignInValidator = {
  email: {
    required: {
      value: true,
      message: "이메일을 입력해주세요.",
    },
    pattern: {
      value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
      message: "올바른 이메일 형식이 아닙니다.",
    },
  },
  password: {
    required: {
      value: true,
      message: "비밀번호를 입력해주세요.",
    },
    minLength: {
      value: 8,
      message: "최소 8자 이상의 비밀번호를 입력해주세요.",
    },
    maxLength: {
      value: 64,
      message: "비밀번호는 64자를 초과하면 안됩니다.",
    },
    pattern: {
      value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
      message: "영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요",
    },
  },
};

해당 객체는 React Hook Form에서 그대로 사용할 수 있는 형태지만, 라이브러리 인터페이스 변경을 고려하여 어댑터를 통해 연결해주었다.

type FormAdaptorProps = {
    register: UseFormRegister<FieldValue<FieldValues>>,
    validator: {[key: string]: RegisterOptions},
    name: string
}

const formAdaptor = ({register, validator, name}: FormAdaptorProps) =>{
    return register(name, {...validator[name]})
}

function SignIn(){
	// ...
  	return (
     <input
         {...formAdaptor({register, validator: SignInValidator, name: 'email'})}
         placeholder="이메일을 입력해주세요."
      />
    )
}

이렇게 구현하면 인터페이스의 수정이 발생했을 때 어댑터 코드만 변경해주면 돼서 비즈니스 로직에 대한 수정은 발생하지 않게 된다.

HTML Attributes의 도메인 컨텍스트 분리

다음은 Input 컴포넌트에서 프로퍼티로 받고 있던 placeholdertype을 완전히 분리해보았다.

interface InputProps {
  props: InputHTMLAttributes<HTMLInputElement>
  errors?: FieldError;
}

const Input = ({props, errors}: InputProps) =>{
    return (
      <label>
        <input
          {...props}						  // [1]
        />
        {errors && <em>{errors.message}</em>} // [2]
      </label>
    );
}
  1. 처음엔 propsformAdaptor를 뿌려주고, type, placeholder 등의 속성을 따로 넣어주었는데, 변경 사항이 생길 때마다 프로퍼티를 추가, 삭제 해줘야 한다는 점이 걸렸다.
    또한 해당 속성들은 input이라는 HTML 요소 자체가 가지고 있는 속성이기 때문에, 한번에 받아서 뿌려주는 것이 좋다고 생각했다.
    React Hook Form의 register 함수 역시 InputHTMLAttributes에 해당하는 프로퍼티만 리턴하기 때문에 함께 사용해도 문제될 게 없었다.

  2. error는 옵션으로 처리했다.

아래는 사용 예시이다.

function SignIn(){
	// ...
  	return (
      <Input
      	props={{
      		...formAdaptor({
      			register,
      			validator: SignInValidator,
      			name: "email",
      		}),
      		placeholder: "이메일을 입력해주세요.",
		}}
		errors={errors.email}
     />
     <Input
      	props={{
          ...formAdaptor({
            	register,
            	validator: SignInValidator,
            	name: "password",
          }),
          placeholder: "비밀번호를 입력해주세요.",
          type: "password",
		}}
        errors={errors.password}
      />
   )
}

input 관련 데이터를 하나의 객체로

이번에는 form을 공통 컴포넌트로 분리해보자. 로그인, 회원가입, 방으로 입장 등 다양한 폼에서 인풋 개수가 달라질 수 있기 때문에 map을 통해 렌더링해주기로 했다.

로그인 인풋에 들어갈 항목 객체를 다시 정리했다. 해당 객체를 클래스로 관리할 수 없을까 하는 아쉬움이 있는데, 이는 조금 더 고민해본 뒤 적용해보려고 한다.

const SignInInputs = {
  email: {
    attributes: {
      placeholder: "이메일을 입력해주세요.",
    },
    validate: {
      required: {
        value: true,
        message: "이메일을 입력해주세요.",
      },
      pattern: {
        value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
        message: "올바른 이메일 형식이 아닙니다.",
      },
    },
  },
  password: {
    attributes: {
      placeholder: "비밀번호를 입력해주세요.",
      type: "password",
    },
    validate: {
      required: {
        value: true,
        message: "비밀번호를 입력해주세요.",
      },
      minLength: {
        value: 8,
        message: "최소 8자 이상의 비밀번호를 입력해주세요.",
      },
      maxLength: {
        value: 64,
        message: "비밀번호는 64자를 초과하면 안됩니다.",
      },
      pattern: {
        value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
        message: "영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요",
      },
    },
  },
};

공통 컴포넌트를 만들 때 유의해야 할 점

  • 프로퍼티 전달이 많아지면 관리하기 어렵다. 컴포넌트 안에서 선언할 수 있는 건 하자.
  • UI 분기 처리를 위한 프로퍼티가 많아지면 관리가 어렵고 유연성이 떨어진다. children 프로퍼티로 적절히 유연성을 확보하자.

아래 1, 2번 예시를 참고하자.

type InputType = {
  attributes: InputHTMLAttributes<HTMLInputElement>;
  validate: { [option: string]: RegisterOptions };
};

interface FormProps {
  children?: ReactNode;
  onSubmit: SubmitHandler<FieldValues>;
  inputs: {
    [name: string]: InputType;
  };
  buttonText: string;
}

const Form = ({ children, onSubmit, inputs, buttonText }: FormProps) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();										// [1]

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {children}										// [2]
      {Object.entries(inputs).map(([name, input]: [string, InputType]) => (
        <Input
          key={name}
          props={formAdaptor({ register, name, input })}
          errorMessage={String(errors[name]?.message || "")}
        />
      ))}
      <button type="submit">{buttonText}</button>
    </form>
  );
};
  1. 처음에는 useForm() 관련 함수를 props로 받는 구조를 생각했는데, props가 너무 거대해졌다. form 하나당 useForm을 하나씩 가지고 있기 때문에 Form 컴포넌트 안에서 useForm을 선언해주어도 문제가 없다.
  2. 현재는 input 위에 제목 또는 제목, 소제목이 있는 형태의 폼이 존재한다. 도메인 컨텍스트에 따라 변경이 잦을 것으로 예상되어 children을 렌더링하여 변경 가능성에 대비했다.

라이브러리 의존성은 Form 컴포넌트에만

Input 컴포넌트와 React Hook Form 라이브러리를 완전히 독립시키고자 formAdaptorInput 컴포넌트를 다음과 같이 수정했다.

기존에는 에러 객체를 보내주어 error.message로 렌더링했지만, Input의 관심사는 결국 에러 '메시지'를 렌더링하는 것이기 때문에 에러 메시지를 props로 전달했다.

type FormAdaptorProps = {
  register: UseFormRegister<FieldValue<FieldValues>>;
  input: InputType;
  name: string;
};

const formAdaptor = ({ register, input, name }: FormAdaptorProps) => {
  const { validate, attributes } = input;
  return { ...register(name, { ...validate }), ...attributes };
};


interface InputProps {
  props: InputHTMLAttributes<HTMLInputElement>;
  errorMessage?: string;
}

const Input = ({ props, errorMessage }: InputProps) => {
  return (
    <label>
      <input {...props} />
      {errorMessage && <em>{errorMessage}</em>}
    </label>
  );
};

최상단에서 다음과 같이 호출할 수 있다. form, input, button으로 구성된 어떠한 요구사항에도 사용할 수 있는 공통 컴포넌트가 되었다.

function SignIn() {
  const signIn = (data: FieldValues) => {
    // ...
  };

  return (
    <div>
      <Form onSubmit={signIn} inputs={SignInInputs} buttonText={"로그인"}>
        <div>로그인</div>
      </Form>
    </div>
  );
}

디자인 변경 사항에 유연하게 대응해야 한다.

실제 서비스를 개발, 운영하다 보면 디자인의 변경이 빈번하게 일어난다(이건 깜지를 개발하면서 호되게 겪었다).

이를 대응하는 과정에서
1. 같은 기능인데 다른 디자인을 가진 컴포넌트가 너무 많이 생성되거나
2. 하나의 컴포넌트의 크기가 매우 커지는 일이 발생할 수 있다.

공통 컴포넌트 외부에서 스타일을 주입하면 디자인이 변경 됐을 때 컴포넌트의 변경 없이 해당 스타일을 변경하거나, 다른 스타일을 주입할 수 있다.

근데 이걸 어떻게 구현하지? 막막했던 이유는 Styled Components 라이브러리를 사용하고 있었기 때문이다.
디자인 변경 사항에 유연하게 대응하려면 클래스 등을 통해 스타일을 수정할 수 있게 만드는 게 좋다. 하지만 Styled Components를 사용하고 있으므로 클래스 스타일링을 추가하면 컨벤션이 망가지고, 당연히 코드 관리도 어려워진다.

현재 Text Me의 스타일 관리 상황


버튼만 봐도 색상, 크기, 모서리 방향, 아이콘 여부 등 다양한 변경 사항이 존재한다.

부끄럽지만 반성의 의미로 올려보는 Text Me의 스타일 관리 상황이다.

Styled Components를 사용하면서 자주 사용되는 디자인은 정의를 해두었는데, 조금이라도 변경이 발생하면 컴포넌트 파일 안에 새로 정의해서 사용했었다. 재사용성 측면에서 매우 나쁜 상태이다.

Button 공통 컴포넌트

Button 공통 컴포넌트를 만들고 모서리와 색상 변경 사항에 대해 대응해보려고 한다.

import React, { ReactNode } from "react";
import styled from "styled-components";

interface ButtonProps {
  children: ReactNode;
  props?: ButtonHTMLAttributes<HTMLButtonElement>;
}

function Button({ children, props }: ButtonProps) {
  return <Default {...props}>{children}</Default>;
}

export default Button;

const Default = styled.button`
  display: flex;
  align-items: center;
  justify-content: center;

  width: 100%;
  padding: 13px 20px;

  font-weight: 700;
  line-height: 17px;

  border: none;

  box-shadow: 2px 2px 5px 1px rgba(62, 78, 82, 0.4),
    inset -2px -2px 3px rgba(106, 106, 106, 0.25),
    inset 2px 2px 3px rgba(255, 255, 255, 0.5);

  cursor: pointer;

  &:focus {
    outline: none;
  }
`;

변경 가능성이 낮은 스타일들로만 구성된 버튼 컴포넌트를 만들었다.
Styled Component 라이브러리 자체가 상속을 지원하기 때문에 하나의 파일에 버튼 관련된 스타일 컴포넌트를 만들어서 <Button>, <LeftButton>, <WhiteButton>처럼 사용해왔다.
버튼처럼 간단한 컴포넌트에서는 <Button>버튼</Button>, <LeftButton onClick={onClick}>왼쪽 버튼</LeftButton>처럼 스타일을 위해서 새로운 컴포넌트를 만들어 사용해도 문제가 없다.

하지만 Input 예제처럼 복잡한 경우, 스타일을 위해서 새로운 컴포넌트를 만들 수는 없다. 위에서 이야기 했듯, 같은 기능인데 다른 디자인을 가진 컴포넌트가 너무 많이 생성될 수 있다. 따라서 공통 컴포넌트 외부에서 스타일을 주입해줘야 한다.

스타일 컴포넌트를 프로퍼티로 주입

const Form = () =>{
	return (
     	<Button props={{ type: "submit" }} Style={Default}>
      		{buttonText}
		</Button>
    );
}
interface ButtonProps {
  children: ReactNode;
  props?: ButtonHTMLAttributes<HTMLButtonElement>;
  Style: StyledComponent<"button", any, {}, never>;
}

function Button({ children, props, Style }: ButtonProps) {
  return <Style {...props}>{children}</Style>;
}

주입된 스타일 컴포넌트를 태그로 사용하여 렌더링한다.

기본으로 사용되는 스타일이 있는 경우 스타일 지정을 하지 않아도 될 것 같아 옵션으로 수정해보았다.

import { Default } from "./ButtonStyle";

interface ButtonProps {
  children: ReactNode;
  props?: ButtonHTMLAttributes<HTMLButtonElement>;
  Style?: StyledComponent<"button", any, {}, never>;
}

function Button({ children, props, Style = Default }: ButtonProps) {
  return <Style {...props}>{children}</Style>;
}

스타일 컴포넌트 상속과 조합

이번엔 초록색이며, 오른쪽 아래 코너가 뾰족한 버튼을 만들어보자.

색상, 모서리라는 키워드를 분리해서 관리하고 싶었다. 상속을 통해 구현하기 위해 다음 같이 작성해보았다.

const Green = styled(Button)`
  color: #ffffff;
  background: #0eca92;

  &:focus {
    background: #8cebb8;
    color: #0eca92;
  }
`

const GreenRightCorner = styled(Green)`
  border-radius: 10px 10px 0px 10px;
`

하지만 이런 경우 WhiteRightCorner 버튼을 만들고 싶을 때 White를 먼저 만들어야 하고 만약 그게 사용되지 않는다면 쓸데없이 컴포넌트 수만 많아진다.

그렇다고 해서 Default를 상속해서 GreenRightCornerWhiteRightCorner를 만들고 색상, 모서리를 한번에 변경하면 코드 관리가 어려워질 것 같았다.

색상, 모서리에 관한 코드를 따로 작성하고 조합해서 스타일 컴포넌트를 만들 수 없을까? 라는 생각에 찾아보다가 css 라는 헬퍼 메서드를 발견했다.

const green = css`
  color: #ffffff;
  background: #0eca92;

  &:focus {
    background: #8cebb8;
    color: #0eca92;
  }
`;

const rightCorner = css`
  border-radius: 10px 10px 0px 10px;
`;

const GreenRightCorner = styled(Default)`
  ${green}
  ${rightCorner}
`;

Default에 green과 rightCorner를 추가해서 GreenRightCorner를 만들었다. 내가 원하던 코드다!

색상 추가, 코너 추가는 다음과 같이 객체로 관리할 수 있다. 컨벤션이나 변수 관리 부분은 고민을 해야겠지만, 나름 체계적으로 관리할 수 있는 방법을 찾은 것 같아서 가슴이 두근두근 거렸다.

const color = {
  green: css`
    color: #ffffff;
    background: #0eca92;

    &:focus {
      background: #8cebb8;
      color: #0eca92;
    }
  `,
  white: css`
    color: "#000000";
    background: #ffffff;
  `,
};

const corner = {
  left: css`
    border-radius: 10px 10px 10px 0px;
  `,
  right: css`
    border-radius: 10px 10px 0px 10px;
  `,
};

const GreenRightCorner = styled(Default)`
  ${color.green}
  ${corner.right}
`;

const WhiteLeftCorner = styled(Default)`
  ${color.white}
  ${corner.left}
`;

최종적으로 이렇게 사용할 수 있다.

const Form = () =>{
	return (
      <Button props={{ type: "submit" }} Style={GreenRightCorner}>
        {buttonText}
      </Button>
    );
}

사실 디자인 시스템에 대해서는 부족한 점이 너무 많다. 사용되는 색상 코드를 일일히 입력해줘야 된다던가, 모서리 radius가 변경되는 경우에 일일히 바꿔줘야 된다던가... 이러한 점에 대해선 지속적으로 고민하고 개선해 나가야겠다.

또한, 이 방식은 Styled Components를 사용하는 주된 방식에서 벗어나있기 때문에 어딘가 어색해보이기도 한다. 이런 식으로 UI를 관리하는 게 최선의 방법은 아닐 수 있다. 하지만 이미 프로젝트 전체에 적용된 CSS 라이브러리를 유지하면서 변경 가능성에 대비하기 위한 방법을 고안해보았다.

마치며

사전 질문을 받고 어떤 식으로 답변을 해야 할지 막막했다. 이러한 막막함을 해소하는 방법으로 직접해보자!를 골랐고, 마침 최근 리팩터링 중이었던 프로젝트에 존재하는 문제를 통해서 질문에 대한 답을 찾아갈 수 있었다.

평소 고민하고 있던 문제에 대해 더 깊이 생각하고, 실제 코드에 적용해볼 수 있는 기회가 되어서 유익하고 재밌는 시간이었다.

최종적으로 제출할 에세이를 작성할 때도, 아무것도 없는 채로 열 줄 짜리 글을 쓰는 것보다 백 줄 짜리 글을 써놓고 열 줄로 요약하는 게 적어도 나한텐 훨씬 편했다.

그리고 너무 오랜만에 블로그 포스트를 작성하는 것이 민망하다. 개인적인 이야기지만 꽤 오랫동안 무기력함을 겪었었다. 이 포스트를 계기로 다시 적극적으로 기술을 탐구하고 공유하는 삶으로 나아가고 싶다.

해당 포스트에 대한 의견이나 조언이 있으시면 댓글로 나누어주세요! 감사합니다.

도움이 된 아티클

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

0개의 댓글