리액트에서 SOLID 원칙을 어떻게 지킬 수 있을까

이주영·2024년 4월 17일
0

리액트

목록 보기
1/1
post-thumbnail

들어가기 앞서

해당 블로그는 돈워리 로딩 페이지 리팩터링을 하기 위한 학습 블로그 입니다. 링크를 통해 리팩터링 과정을 확인 하실 수 있습니다.

본래 좋은 소프트웨어란 변화에 대응을 잘 하는 것을 말합니다. SOLID 객체 지향 원칙에 입각해서 컴포넌트를 만들게 되면 코드의 확장성과 유지보수성을 더 나아가 복잡성을 제거해서 리팩터링에 소요되는 시간도 줄일 수 있습니다.결과적으로 프로젝트 개발의 생산성을 높일 수 있게 되는 것이죠.

하나씩 살펴보시죠.

1. 단일 책임 원칙 (SRP)

핵심은 하나의 컴포넌트는 하나의 기능만!

단일 책임 원칙은 말 그대로 클래스(객체)는 하나의 책임만 해야 한다는 원칙입니다. 책임이란 기능으로 생각하면 됩니다. 만약 하나의 클래스에 기능이 많다면 수정에 취약한 코드가 되겠죠. 이를 위해 분리하는 것을 강조합니다. 결과적으로 프로그램의 유지보수성을 높이기 위한 설계 기법입니다.

예제를 활용해서 살펴봅시다.


//SRP가 지켜지지 않은 코드 ❌❌❌❌❌❌
export function EditUserProfileBAD() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    password: "",
    image: null,
  });

  const [errors, setErrors] = useState({
    name: "",
    email: "",
    password: "",
  });

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value,
    });

    // 유효성 검사 기능 
    if (name === "name") {
      setErrors({
        ...errors,
        name: value.trim() === "" ? "Name is required" : "",
      });
    } else if (name === "email") {
      setErrors({
        ...errors,
        email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
          ? ""
          : "Invalid email address",
      });
    } else if (name === "password") {
      setErrors({
        ...errors,
        password: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test(value)
          ? ""
          : "Password must meet the criteria",
      });
    }
  };

  const handleImageChange = (e) => {
    const file = e.target.files[0];
    setFormData({
      ...formData,
      image: file,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      const response = await axios.post(
        "http://localhost:9000/user/update",
        formData
      );

      console.log("Data Saved");
    } catch (err) {
      throw new Error(
        "Error occurred when trying to save your profile changes!"
      );
    }
  };

  return (
    <div className="flex flex-col max-w-md p-4">
      <h1 className="text-2xl font-bold mb-4">Edit User Profile</h1>
      <form
        className="flex flex-col items-start"
        onSubmit={handleSubmit}
      >
        <div className="flex flex-col mb-4">
          <label className="font-bold text-left">
            Profile Picture:
          </label>
          {formData.image && (
            <div className="mt-2 mb-2">
              <img
                src={URL.createObjectURL(formData.image)}
                alt="Profile Preview"
                className="w-32 h-32 object-cover rounded-full"
              />
            </div>
          )}
          <input
            type="file"
            accept="image/*"
            name="image"
            onChange={handleImageChange}
            className="text-xs"
          />
        </div>
        <div className="flex flex-col mb-4">
          <label className="text-left font-bold">Name:</label>
          <input
            className="rounded-sm h-8 p-4"
            type="text"
            name="name"
            value={formData.name}
            onChange={handleInputChange}
          />
          <div className="text-red-500">{errors.name}</div>
        </div>
        <div className="flex flex-col mb-4">
          <label className="text-left font-bold">Email:</label>
          <input
            className="rounded-sm h-8 p-4"
            type="email"
            name="email"
            value={formData.email}
            onChange={handleInputChange}
          />
          <div className="text-red-500">{errors.email}</div>
        </div>
        <div className="flex flex-col mb-4">
          <label className="text-left font-bold">Password:</label>
          <input
            className="rounded-sm h-8 p-4"
            type="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
          />
          <div className="text-red-500">{errors.password}</div>
        </div>
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          Update Profile
        </button>
      </form>
    </div>
  );
}

SRP의 핵심은 하나의 클래스 혹은 객체 안에서 하나의 기능만 동작하는 것입니다.

자바스크립트에서 함수는 객체이므로 함수에도 적용되고 리액트 컴포넌트 역시 함수... 이기에 리액트에도 적용된다고 할 수 있습니다.위의 코드는 일반적인 회원가입 폼입니다.

EditUserProfileBAD 컴포넌트에 어떤 기능들이 복합적으로 있는지 살펴보겠습니다.

1. 폼 제출 기능
2. 유효성 검사 기능
3. 이미지 업로드 기능
4. 유저 인풋 상호작용 기능

4가지의 기능들이 현재 하나의 컴포넌트 안에서 모두 일어나고 있습니다. SRP에 맞게 코드를 변경한다면 어떻게 변경할 수 있을까요?!

typescript에 호환이 잘되는 zod 유효성 검사 라이브러리와 react-hook-form 라이브러리를 활용하여 빠르게 아래와 같이 리팩터링할 수 있습니다.

interface UserFormInput {
  name: string;
  email: string;
  password: string;
}

const validationSchema = z
  .object({
    name: z.string().min(1, "Please enter your name"),
    email: z.string().email("Please enter a valid email"),
    password: z
      .string()
      .regex(
        /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/,
        "Please enter a strong password"
      ),
  })
  .required();

//SRP가 지켜진 코드 ✅✅✅✅✅
export function EditUserProfileGOOD() {
  const onSubmit = async (data) => {
    console.log("Sending data to the API:", data);
    await updateUserProfile(data);
  };

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserFormInput>({
    resolver: zodResolver(validationSchema),
  });

  return (
    <div className="flex flex-col max-w-md p-4">
      <h1 className="text-2xl font-bold mb-4">Edit User Profile</h1>
      <form
        className="flex flex-col items-start"
        onSubmit={handleSubmit(onSubmit)}
      >
        <ProfilePictureUploader />
        <InputField
          labelText="Name"
          fieldRegister={register("name")}
          error={errors.name?.message}
        />
        <InputField
          labelText="Email"
          fieldRegister={register("email")}
          error={errors.email?.message}
        />
        <InputField
          labelText="Password"
          fieldRegister={register("password")}
          error={errors.password?.message}
        />
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          Update Profile
        </button>
      </form>
    </div>
  );
}

우선 EditUserProfileGOOD 컴포넌트에서의 역할은 한가지 입니다. 폼을 제출하는 기능 뿐이죠. 그렇다면 나머지 세가지 기능 (유저 인풋 기능, 유효성 검사 기능, 이미지 업로드 기능)은 어디서 동작하도록 변경되었을까요?!

1. 유저 인풋 기능

따로 컴포넌트를 만들어주었습니다.

//InputField.tsx
import { UseFormRegisterReturn } from "react-hook-form";

interface FormFieldProps
  extends React.HTMLAttributes<HTMLInputElement> {
  labelText: string;
  fieldRegister: UseFormRegisterReturn;
  error?: string;
}

export function InputField(props: FormFieldProps) {
  const { labelText, fieldRegister, error, ...restProps } = props;

  return (
    <div className="flex flex-col mb-4">
      <label className="text-left font-bold">{labelText}</label>
      <input
        className="rounded-sm h-8 p-4"
        type="text"
        {...restProps}
        {...fieldRegister}
      />
      {error && <div className="text-red-500">{error}</div>}
    </div>
  );
}

2. 이미지 업로드 기능

export function ProfilePictureUploader() {
  const [imageData, setImageData] = useState(null);

  const uploadImageToServer = async (image) => {
    await axios.post("http://localhost:9000/image/upload", { image });
  };

  const handleImageChange = async (e) => {
    const file = e.target.files[0];
    setImageData(file);
    await uploadImageToServer(file);
  };

  return (
    <div className="flex flex-col mb-4">
      <label className="font-bold text-left">Profile Picture:</label>
      {imageData && (
        <div className="mt-2 mb-2">
          <img
            src={URL.createObjectURL(imageData)}
            alt="Profile Preview"
            className="w-32 h-32 object-cover rounded-full"
          />
        </div>
      )}
      <input
        type="file"
        accept="image/*"
        name="image"
        onChange={handleImageChange}
        className="text-xs"
      />
    </div>
  );
}

캡슐화가 잘 진행되어 이미지 업로드와 관련된 상태만 해당 컴포넌트에서 가지고 있도록 하고 외부에서 수정이 불가하도록 리팩터링할 수 있습니다.

3. 유효성 검사 기능

zod라는 라이브러리를 활용하여 정적으로 상수에 스키마를 만들었습니다.

//schme.ts
const validationSchema = z
  .object({
    name: z.string().min(1, "Please enter your name"),
    email: z.string().email("Please enter a valid email"),
    password: z
      .string()
      .regex(
        /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/,
        "Please enter a strong password"
      ),
  })
  .required();
  
  //EditUserProfileGOOD.Tsx
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserFormInput>({
    resolver: zodResolver(validationSchema),
  });

useForm 매개변수로 resolver 프로퍼티의 정적인 유효성 조건 상수를 추가해줍니다. 이로써 한 컴포넌트에 있는 모든 기능들을 분리할 수 있게 됐습니다.

2. 개방 폐쇄 원칙 (OCP)

개발 폐쇄 원칙은 클래스(객체)는 "확장에 열려있어야 하지만 수정에는 닫혀있어야 한다."를 뜻합니다. 와닿지 않네요 ㅎㅎ. 아래와 같은 이미지의 코드를 예제로 활용해서 이해해봅시다.

코드를 살펴보시죠.

function DropdownItem({ hideIcon, icon, name, description }) {
  return (
    <div className="flex items-center px-2 py-2 cursor-pointer hover:bg-slate-200 transition-all">
      {!hideIcon && (
        <div className="flex items-center justify-center text-2xl bg-gray-100 text-blue-500 w-7 h-7 rounded-md p-1.5 mr-2">
          {icon}
        </div>
      )}
      <div className="flex flex-col">
        <span className="font-bold">{name}</span>
        <p className="text-xs text-gray-400">{description}</p>
      </div>
    </div>
  );
}
 
interface DropdownProps {
  title: string;
  items: any[];
  hideIcons?: boolean;
}

export function Dropdown(props: DropdownProps) {
  const { title, items, hideIcons } = props;
  const [isDropdownOpen, setDropdownOpen] = useState(false);

  const toggleDropdown = () => {
    setDropdownOpen(!isDropdownOpen);
  };

  return (
    <div className="relative inline-block text-left">
      <button
        onClick={toggleDropdown}
        type="button"
        className="px-4 py-2 bg-blue-500 text-white rounded focus:outline-none focus:bg-blue-600"
      >
        {title}
      </button>

      {isDropdownOpen && (
        <div className="origin-top-left absolute left-0 mt-2 w-48 bg-white border border-blue-400 rounded shadow-lg text-black">
          {items.map((item, index) => (
            <DropdownItem
              key={index}
              {...item}
              hideIcon={hideIcons}
            />
          ))}
        </div>
      )}
    </div>
  );
}

// 나쁘진 않지만 수정이 있을때 확장하기 어려운 구조입니다.
export function DropdownBAD() {
  const items = [
    {
      icon: <FaDraftingCompass />,
      name: "New Project",
      description: "Kickoff a new project",
    },
    {
      icon: <FaDraftingCompass />,
      name: "New Draft",
      description: "Unleash your skills",
    },
    {
      icon: <FaPager />,
      name: "New Page",
      description: "Start simple",
    },
  ];

  return (
    <div className="container mx-auto p-4">
      <Dropdown title="Create +" items={items} hideIcons={false} />
    </div>
  );
}

DropdownBAD 컴포넌트에서는 Dropdown 컴포넌트에 데이터를 주입하여 화면에 그려주는 역할을 하고 있습니다. Dorpdown 컴포넌트는 받은 3개의 props를 활용하여 유저가 클릭했는지 아닌지에 따라 화면에 보여주는 기능을 하는 컴포넌트입니다. DropdonwItem 컴포넌트는 받은 props를 UI로 표현하는 역할의 컴포넌트입니다.

위의 컴포넌트는 SOLID 원칙 중 첫 번째 원칙인 SRP인 단일 책임 원칙이 지켜진 코드라고 보입니다. 또한 데이터와 UI를 분리하여 상수로 관리하고 있다는 점에서 충분히 확장성을 고려한 컴포넌트 설계라고 보입니다.

만약 UX 리서치를 통해 디자인 수정이 생긴다면?? 제목과 상세 설명 위치가 바뀐다면? 드롭다운 하단에 footer section이 생긴다면?! 이런 작은 수정은 구조가 독립적이지 않다면 어려울 수 있습니다. 자칫 잘못
하면 컴포넌트를 복잡하게 만들고 유지보수가 어렵게 만듭니다.

그래서 확장에는 열려있어야하고 수정에는 닫혀있어야 하는 원칙을 지켜 리팩터링 해보려고 합니다. 즉 수정에 있어서 많은 코드를 건드리지 않고 필요한 코드만 추가할 수 있어야한다는 맥락으로 이해할 수 있습니다.

컴파운트 패턴을 활용합시다.!!

//상위 컴포넌트.tsx
export function DropdownGOOD() {
  return (
    <Dropdown>
      <Dropdown.Button>Create +</Dropdown.Button>
      <Dropdown.List>
        <Dropdown.Item
          icon={<BiCodeAlt />}
          description="Start a Project"
        >
          New Project
        </Dropdown.Item>
        <Dropdown.Item
          icon={<FaDraftingCompass />}
          description="Scafold a new Draft"
        >
          New Draft
        </Dropdown.Item>
        <Dropdown.Item
          icon={<FaPager />}
          description="Create another Page"
        >
          New Page
        </Dropdown.Item>
        {/* You can easily customized it however you want while the dropdown building blocks are still the same */}
        <span className="px-1 text-xs text-gray-400 leading-5">
          All projects will be auto saved
        </span>
      </Dropdown.List>
    </Dropdown>
  );
}```


1. Dropdown 컴포넌트 내에서 사용할 컨텍스트를 만듭니다.

```tsx
//Dropdown.tsx
const DropdownProvider = createContext<{
  isDropdownOpen: boolean;
  toggleDropdown: () => void;
}>({ isDropdownOpen: false, toggleDropdown: () => {} });
  1. 위에서 만든 컨텍스트 Provider로 래핑한 Root 컴포넌트 구현합니다.
const Dropdown = (props) => {
  const [isDropdownOpen, setDropdownOpen] = useState(false);

  const toggleDropdown = () => {
    setDropdownOpen(!isDropdownOpen);
  };

  return (
    <div className="relative inline-block text-left">
      <DropdownProvider.Provider
        value={{ toggleDropdown, isDropdownOpen }}
      >
        {props.children}
      </DropdownProvider.Provider>
    </div>
  );
}

해당 루트 컴포넌트에서 내부 상태를 전역 상태에 넣어 하위 컴포넌트에서 드롭 다운의 상태와 이벤트 핸들러를 사용할 수 있도록 합니다.

  1. DropDown 컴포넌트 안에 있는 버튼 구현하기
const Button = ({children} :PropWithChildren) => {
  const { isDropdownOpen, toggleDropdown } =
    useContext(DropdownProvider);

  return (
    <button
      onClick={toggleDropdown}
      type="button"
      className="px-4 py-2 bg-blue-500 text-white rounded focus:outline-none focus:bg-blue-600"
    >
      {children}
    </button>
  );
}

전역 상태와 동기화하는 역할을 컴포넌트입니다.

  1. 그럼 이제 DropDown 리스트 컴포넌트를 구현해보겠습니다.
const List = ({children} :PropWithChildren) => {
  const { isDropdownOpen, toggleDropdown } =
    useContext(DropdownProvider);

  if (!isDropdownOpen) return null;

  return (
    <div className="origin-top-left absolute left-0 mt-2 w-48 bg-white border border-blue-400 rounded shadow-lg text-black">
      {children}
    </div>
  );
}

List 컴포넌트에선 children을 마찬가지로 받고 유저가 클릭하지 않을 경우 null을 리턴하도로 구현하였습니다.

  1. 마지막으로 Item 컴포넌트를 구현합니다.
const Item = ({  hideIcon, icon, children, description } :ItemProps) => {


  return (
    <div className="flex items-center px-2 py-2 cursor-pointer hover:bg-slate-200 transition-all">
      {!hideIcon && (
        <div className="flex items-center justify-center text-2xl bg-gray-100 text-blue-500 w-7 h-7 rounded-md p-1.5 mr-2">
          {icon}
        </div>
      )}
      <div className="flex flex-col">
        <span className="font-bold">{children}</span>
        <p className="text-xs text-gray-400">{description}</p>
      </div>
    </div>
  );
}```
children을 가지고 있으며 마찬가지로 받은 props를 UI로 표현하는 컴포넌트입니다. 

6. 위의 함수를 묶어 export합니다.
```tsx
export default Object.assign(Dropdown, { List, Item, Button });

이런식으로 구현하지 않고 일일이 export를 붙혀줄 수 있지만 보기 좋게 Object.assign을 활용하여 객체의 프러퍼티에 추가하여 구현할 수 있습니다.

컴파운드 패턴을 활용하여 확장에 용이하고 수정에 폐쇄적인 컴포넌트 설계를 구현할 수 있습니다. 하지만 단점도 있기 마련이죠. 간단한 컴포넌트를 컴파운드 패턴을 적용하면 부득이하게 코드가 많아지며 되려 복잡성을 증가시킬 수 있다는 것입니다. 그럼에도 불구하고 Dropdown 컴포넌트는 수정이 잦을 수 있기 때문에 컴파운드 패턴으로 구현하는기 좋은 컴포넌트라고 생각합니다.

결과

컴파운트 패턴으로 구현하는 것이 정답은 아닙니다. 만약 작은 기능의 컴포넌트라면 props로 처리할 수 있겠죠!!

3. 리스코프 치환 원칙(LSP)

어느 블로그의 그림이 와닿아 활용하려고 합니다.
이미지 출처 : https://velog.io/@wns450/S.O.L.I.D-%EC%9B%90%EC%B9%99

LSP 원칙은 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다. "부모 컴포넌트의 타입을 자식 컴포넌트 타입으로 치환해도 정상적으로 적동해야한다"입니다. 그렇다면 리액트에 어떻게 적용할 수 있을까요?!

어떤 컴포넌트가 특정한 props를 요구하면, 그것을 상속하거나 확장하는 다른 컴포넌트도 같은 props를 받아들이고 동일한 방식으로 처리해야 한다고 생각할 수 있습니다.

그래서 동료 개발자가 제가 만든 컴포넌트를 사용할 때 쉽고 컴포넌트를 교체하거나 확장할 수 있습니다. 즉 예측 가능하고 안정적인 컴포넌트 시스템을 만드는 것이죠.

4. 인터페이스 분리 원칙 (ISP)

인터페이스를 각각 사용에 맞게 잘게 분리하라는 의미입니다. 즉 컴포넌트는 사용하지 않는 props는 지양하는 것을 의마합니다.

아래의 예제를 통해 살펴보시죠.

interface User {
  name: string;
  email: string;
}

interface Project {
  name: string;
}

interface NotificationProps {
  user?: User;
  project?: Project;
}

const Notification = ({ project, user }: NotificationProps) => {
  if (project) {
    return (
      <div
        className="flex flex-col items-center rounded-md fixed bottom-4 left-2 bg-blue-100 border-t border-b border-blue-500 text-blue-700 px-5 py-2"
        role="alert"
      >
        <span className="">
          <IoMdNotifications />
        </span>
        <p className="font-bold">Project Export Finished</p>
        <p className="text-sm">{project?.name}</p>
      </div>
    );
  } else if (user) {
    return (
      <div
        className="flex flex-col items-center rounded-md fixed bottom-4 left-2 bg-green-100 border-t border-b border-green-500 text-green-700 px-5 py-2"
        role="alert"
      >
        <span className="">
          <IoMdNotifications />
        </span>
        <p className="font-bold">Project Export Finished</p>
        <p className="text-sm">{user?.email}</p>
      </div>
    );
  } else {
    return null;
  }
};

Notification 컴포넌트의 JSX를 확인해보면, 받는 props가 있는지 없는지에 따라 다른 JSX를 반환하고 있습니다. 즉 해당 컴포넌트의 props 중 하나는 항상 사용하지 않는 props라는 것이죠. 그렇다면 어떻게 LSP 관점에서 컴포넌트를 설계할 수 있을까요?!

Notification 컴포넌트를 분리하는 것.

현재 Notification 컴포넌트의 props를 기준으로 userNotification을 보여주거나 projectNotification을 보여줬습니다. props를 기준하지 않고 두개의 컴포넌트로 만들어 불필요한 props은 사용하지 않도록 구현할수 있습니다.

//GOOD ✅
export function UserProfileGOOD() {
  const user = {
    name: "John Doe",
    email: "john.doe@example.com",
  };

  const project = {
    name: "Landing Page",
  };

  return (
    <div>
      <h2 className="font-bold">User Dashboard</h2>
      <UserNotification user={user} />
      {/* <ProjectNotification project={project} /> */}
    </div>
  );
}

사용하지 않는 props가 있는가를 기준으로 컴포넌트를 분리하는 것은 어떨까요?!

5. 의존 역전 원칙 (DIP)

"컴포넌트 혹은 모듈 그리고 함수는 구체적인 컴포넌트에 의존하면 안 되며, 되려 공통의 추상체에 의존해야 한다."입니다. 서비스 레이어를 만들 수도 있고 커스텀 훅을 활용하여 DIP를 적용할 수 있습니다. 커스텀 훅을 활용한 예제를 활용하여 이해해 봅시다.

const ActiveUsersList = () => {
	const [users, setUsers] = useState([]);

	useEffect(() => {
	const loadUsers = async() => {
		const response = await fetch('/some-api')
		const data = await resonse.json()
		setUsers(data)
	}

	loadUsers()
	},[])
	...
}

위의 코드에서 ActiveUsersList라는 컴포넌트의 역할은 받은 데이터를 UI로 보여주는 역할이기 떄문에 데이터를 분리해보겠습니다. SRP와 DIP 원칙을 지켜서 리팩터링하면 아래와 같이 수정할 수 있겠죠.

const useUsers = () => {
	const [users, setUsers] = useState([]);

	useEffect(() => {
	const loadUsers = async() => {
		const response = await fetch('/some-api')
		const data = await resonse.json()
		setUsers(data)
	}

	loadUsers()
	},[])
	
	return { users }
}

const ActiveUsersList = () => {
	const { users } = useUsers();
}

마치며

돈워리 프로젝트 중 로딩 페이지의 코드를 리팩터링하기 위해선 SOLID를 알아야했습니다. 그래서 컴포넌트 분리와 설계의 기준을 학습하였고 이를 바탕으로 리팩터링을 진행하려고 합니다.

참고

  1. React Clean Code: Advanced Examples of SOLID Principles
  2. [10분 테코톡] 푸만능의 리액트 컴포넌트 설계와 SOLID : https://www.youtube.com/watch?v=atsWCicI5VQ&t=330s
profile
https://danny-blog.vercel.app/ 문제 해결 과정을 정리하는 블로그입니다.

0개의 댓글