TIL

React 코드로 보는 S.O.L.I.D 객체 지향 설계의 5가지 원칙

개발을 하다보면 한번 쯤은 SOLID 원칙을 한번 씩 들어보았을 거라고 생각합니다.

프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 적용할 수 있는데

오늘은 직접 예제코드를 통해 SOLID 객체 지향 설계가 정확히 무엇인지 알아보는 시간을 가져보려고 합니다.

 

😀 SOLID 원칙

- Single Responsibility Principle : 단일 책임 원칙

한 클래스는 하나의 책임만 가져야 한다.

- Open Closed Principle : 개방 폐쇄 원칙

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다

- Listov Substitution Principle : 리스코프 치환 원칙 

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.”

- Interface Segregation Principle : 인터페이스 분리 원칙

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다

- Dependency Inversion Principle : 의존 역전 원칙

프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

 

 

🍪 Single Responsibility Principle(단일 책임 원칙)

 

일단 단일 책임 원칙에 대한 bad 코드를 먼저 보겠습니다.

❌ BAD

import axios from "axios";
import React, { useState } from "react";

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,
    });

    // Basic validation
    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>
  );
}

 

이 코드는 흔한 user profile form 컴포넌트 입니다. 

picture ,name, email, password를 받고 handleInputChange에서 이름, 이메일, 비밀번호를 validate하고 있습니다.

handleImageChange는 이미지 변경을 처리하기 위한 핸들러입니다.

또한 당연하게도 submitbutton을 통해서 프로필을 업데이트를 하고 있습니다. 

누가봐도 이 컴포넌트는 많은 일을 담당하고 있습니다.

 

양식 데이터 이미지와 같이 표시되는 이미지의 미리보기는 이미지를 렌더링하고 이미지를 서버에 업로드하는 역활만 수행하도록

캡슐화 할 수 있을 것 같습니다.

또한 각 입력 필드를 마찬가지로 모든 단일 입력에 대한 별도의 컴포넌트로 만들 수 있을것 같습니다.

 

 

✅ GOOD

import React, { useState } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { InputField } from "./inputField";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ProfilePictureUploader } from "./profilePictureUploader";
import axios from "axios";
import { updateUserProfile } from "./services";

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();

export function EditUserProfileGOOD() {
  const onSubmit = async (data) => {
    // e.preventDefault(); ///< No more

    // Perform API POST request here with formData
    // Replace this with your actual API endpoint and payload
    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>
  );
}

 

우선 form 을 더 쉽고 간단하게 다루기 위한 react-hook-form 라이브러리를 사용했습니다.react-hook-form 을 사용하면 더 적은 코드와 더 빠르게 form 을 다룰 수 있습니다.

또한 zod 라이브러리를 사용하면 Bad 코드에서 보았던 유효성 검사를 위한 수많은 if문을 대체할 수 있습니다.

 

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>
  );
}

각각의 input을 간단한 InputField 컴포넌트로 대체했습니다. InputField는 입력과 오류를 처리하는 입력 필드 역활만을 하는 컴포넌트 입니다. 

react hook form 통해 제공된 fieldRegister를 통해 상태를 추적하고 모든 것을 업데이트 할 수 있습니다.

 

import axios from "axios";
import { useState } from "react";

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>
  );
}

프로필 사진 업로더는 preview를 볼 수 있는 별도의 요소와 이미지를 업로드 할 수 있는 컴포넌트입니다. 이미지는 자동으로 업로드되게 구성했습니다.

 

🍪 Open Closed Principle (개방 폐쇄 원칙)

개방 폐쇄 원칙에 관해서는 dropdown을 예시로 들어보겠습니다.

❌ BAD

import React, { useState } from "react";
import { BiCodeAlt } from "react-icons/bi";
import {
  FaFileCode,
  FaDraftingCompass,
  FaPager,
} from "react-icons/fa";

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에서 items로 항목들을 관리하고 있으며 아이콘을 다른 것으로 원하는 대로 바꿀 수 있습니다.  Dropdown 에서는 toggleDropDown을 통해서 드롭다운을 키고 끌 수 있습니다. 

DropdownItem은 아이콘을 숨기거나 제목과 설명을 렌더링하는 컴포넌트로 SRP를 만족하고 어떻게 보면 OCP원칙을 준수하는 것으로도 볼 수 있습니다. 

하지만 만약 드롭다운 어떤 특정한 요소에 따로 text(예를 들어 2번째 드롭다운에 묘사 아래 추가 정보같은 것)를 더 추가하고 싶다고 가정한다면 이 DropDown 컴포넌트 구조는 확장에 불리 하게 작용합니다.

 

✅ GOOD

import { BiCodeAlt } from "react-icons/bi";
import * as Dropdown from "./dropdown";
import { FaDraftingCompass, FaPager } from "react-icons/fa";

//GOOD ✅
export function DropdownGOOD() {
  return (
    <Dropdown.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.Dropdown>
  );
}

이 DropdownGood 컴포넌트는 버튼과 같은 추가적인 요소를 맘대로 아래로 옮기거나 위치를 바꿀 수 있으며 더 추가적인 정보를 제공하고 싶을때 쉽게 변경할 수 있습니다.

따라서 개방 폐쇄 원칙에 좀 더 만족하는 컴포넌트라고 할 수 있습니다.

import { createContext, useContext, useState } from "react";

const DropdownProvider = createContext<{
  isDropdownOpen: boolean;
  toggleDropdown: () => void;
}>({ isDropdownOpen: false, toggleDropdown: () => {} });

export function 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>
  );
}

export function Button(props) {
  const { children } = props;

  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>
  );
}

export function List(props) {
  const { children } = props;

  const { isDropdownOpen, toggleDropdown } =
    useContext(DropdownProvider);

  console.log("props: ", props);

  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>
  );
}

export function Item(props) {
  const { hideIcon, icon, children, description } = props;

  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>
  );
}

DropDown 컴포넌트에는 useContext Provider를 사용해서 모든 dropdown 요소가 동일한 open, close 상태에 접근할 수 있게 했고 

List 컴포넌트는 열려 있지 않는 경우 null을 반환합니다. 

 

🍪 Listov Substitution Principle (리스코프 치환 원칙 )

리스코프 치환 원칙의 핵심은 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 된다는 것입니다.

❌ BAD

import React, { useState } from "react";

export function PrivacyDialog() {
  const [isOpen, setIsOpen] = useState(true);

  const onAccept = (id: string) => {
    // Handle user's acceptance of the policy
    console.log("US User accepted the Privacy Policy.");
  };

  const onDeny = () => {
    // Handle user's denial of the policy
    console.log("US User denied the Privacy Policy.");
  };

  const accept = () => {
    onAccept("USX11V2");
    setIsOpen(false);
  };

  const deny = () => {
    onDeny();
    setIsOpen(false);
  };

  return (
    <div
      className={`fixed z-10 inset-0 overflow-y-auto ${
        isOpen ? "" : "hidden"
      }`}
    >
      <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
        <div
          className="fixed inset-0 transition-opacity"
          aria-hidden="true"
        >
          <div className="absolute inset-0 bg-gray-500 opacity-75"></div>
        </div>

        <span
          className="hidden sm:inline-block sm:align-middle sm:h-screen"
          aria-hidden="true"
        >
          &#8203;
        </span>

        <div
          className={`inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full ${
            isOpen ? "sm:scale-100" : "sm:scale-95"
          }`}
        >
          <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
            <div className="sm:flex sm:items-start">
              <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
                <h3 className="text-lg leading-6 font-medium text-gray-900 font-black">
                  General Privacy Policy
                </h3>
                <div className="mt-2">
                  <p className="text-sm text-gray-500">
                    ....
                  </p>
                </div>
              </div>
            </div>
          </div>
          <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
            <button
              onClick={accept}
              type="button"
              className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"
            >
              Accept & Continue
            </button>
            <button
              onClick={deny}
              type="button"
              className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
            >
              Deny
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

export function EUPrivacyDialog() {
  const [isOpen, setIsOpen] = useState(true);

  const onAccept = (name: string, id: string) => {
    // Handle user's acceptance of the policy
    console.log("EU User accepted the Privacy Policy.");
  };

  const onDeny = () => {
    // Handle user's denial of the policy
    console.log("EU User denied the Privacy Policy.");
  };

  const accept = () => {
    onAccept("EU", "EUX11V1");
    setIsOpen(false);
  };

  const deny = () => {
    onDeny();
    setIsOpen(false);
  };

  return (
    <div
      className={`fixed z-10 inset-0 overflow-y-auto ${
        isOpen ? "" : "hidden"
      }`}
    >
      <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
        <div
          className="fixed inset-0 transition-opacity"
          aria-hidden="true"
        >
          <div className="absolute inset-0 bg-gray-500 opacity-75"></div>
        </div>

        <span
          className="hidden sm:inline-block sm:align-middle sm:h-screen"
          aria-hidden="true"
        >
          &#8203;
        </span>

        <div
          className={`inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full ${
            isOpen ? "sm:scale-100" : "sm:scale-95"
          }`}
        >
          <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
            <div className="sm:flex sm:items-start">
              <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
                <h3 className="text-lg leading-6 font-medium text-gray-900 font-black">
                  US Privacy Policy
                </h3>
                <div className="mt-2">
                  <p className="text-sm text-gray-500">
                    ...
                  </p>
                </div>
              </div>
            </div>
          </div>
          <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
            <button
              onClick={accept}
              type="button"
              className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"
            >
              Accept & Continue
            </button>
            <button
              onClick={deny}
              type="button"
              className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
            >
              Deny
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

export function DialogBAD() {
  return (
    <div className="container mx-auto p-4">
      {/* <PrivacyDialog /> */}
      <EUPrivacyDialog />
    </div>
  );
}

이 코드는 계약 조건 및 개인 정보 보호 정책을 제공하고 제출하는 컴포넌트 입니다. 홈페이지에 들어갈지 서비스를 이용하는 것을 거부 할지 선택하는 많은 페이지에서 사용하고 있는 컴포넌트입니다.

EUPrivacy 컴포넌트와 Privacy 컴포넌트는 구조적으로 거의 비슷해보이지만 onAccept를 보면 제공하는 매개변수(parameter)는 다른것을 알 수 있습니다.

물론 EUPrivacy 와 Privacy Dialog는 교환해서 사용 할 수 있지만 LSP(리스코프 원칙)을 따르지 않는다고 볼 수 있으므로 PrivacyDialog 컴포넌트를 삭제하고 EUPrivacy 컴포넌트로 대체한다면 기술적으로 동일한 방식으로 작동할 것으로 기대할 수 없게 됩니다. 

따라서 PrivacyDialog에서 EUPrivacy로 또는 그 반대로 쉽게 대체할 수는 없습니다.

 

 

✅ GOOD

import { EUPrivacyPolicyDialog } from "./EUPrivacyPolicyDialog";
import { PrivacyPolicyDialog } from "./PrivacyPolicyDialog";

//GOOD ✅
export function DialogGOOD() {
  const handleAccept = (id: string) => {
    // Handle user's acceptance of the policy
    console.log("User accepted the Privacy Policy. with id" + id);
  };

  const handleDeny = () => {
    // Handle user's denial of the policy
    console.log("User denied the Privacy Policy.");
  };

  return (
    <div className="container mx-auto p-4">
      {/* Show the Dialog component based on your application logic */}
      {/* <PrivacyPolicyDialog
        onAccept={handleAccept}
        onDeny={handleDeny}
      /> */}
      <EUPrivacyPolicyDialog
        onAccept={handleAccept}
        onDeny={handleDeny}
      />
    </div>
  );
}
import React, { useState } from "react";
import { PrivacyPolicyDialogProps } from "./privacyPolicy";

export function EUPrivacyPolicyDialog({
  onAccept,
  onDeny,
}: PrivacyPolicyDialogProps) {
  const [isOpen, setIsOpen] = useState(true);

  const accept = () => {
    onAccept("EUX11V1");
    setIsOpen(false);
  };

  const deny = () => {
    onDeny();
    setIsOpen(false);
  };

  return (
    <div
      className={`fixed z-10 inset-0 overflow-y-auto ${
        isOpen ? "" : "hidden"
      }`}
    >
      <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
        //생략
    </div>
  );
}

import React, { useState } from "react";
import { PrivacyPolicyDialogProps } from "./privacyPolicy";

export function PrivacyPolicyDialog({
  onAccept,
  onDeny,
}: PrivacyPolicyDialogProps) {
  const [isOpen, setIsOpen] = useState(true);

  const accept = () => {
    onAccept("USX11V2");
    setIsOpen(false);
  };

  const deny = () => {
    onDeny();
    setIsOpen(false);
  };

  return (
    <div
      className={`fixed z-10 inset-0 overflow-y-auto ${
        isOpen ? "" : "hidden"
      }`}
    >
      //생략
    </div>
  );
}

EUPrivacy, PrivacyPolicyDialog컴포넌트는 이름이나 다른 것을 사용하지 않고 정확히 id만 사용하므로 새 개인 정보 보호 정책을 추가할 때마다 계속해서 동일한 인터페이스를 사용해야 합니다.

또한 동일한 인터페이스를 따르기 때문에 동일한 매개변수를 사용하므로 컴포넌트를 교체할 때는 매우 쉽게 교체할 수 있습니다. 

LSP원칙을 사용하고 따른다는 건 컴포넌트를 매우 쉽게 교체 할 수 있다는 것을 의미합니다.

 

🍪 Interface Segregation Principle (인터페이스 분리 원칙)

 ISP원칙의 정의는 컴포넌트는 필요하지 않은 props에 의존해서는 안된다는 것입니다.

❌ BAD

import { IoMdNotifications } from "react-icons/io";

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

interface Project {
  name: string;
}

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

const Notification = ({ project, user }: NotificationProps) => {
  if (project) {
    // Display a project notification
    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) {
    // Display a user notification
    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;
  }
};

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

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

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

Notification은 project, user 두개의 객체를 가져옵니다. 하지만 정작 사용하는 객체는 둘 중 하나가 됩니다. 따라서 ISP 원칙을 따르지 않는 컴포넌트라고 할 수 있습니다.

그러면 간단하게 project만 필요한 컴포넌트 , user만 필요한 컴포넌트를 만들면 되겠습니다.

 

✅ GOOD

import { IoMdNotifications } from "react-icons/io";

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

interface Project {
  name: string;
}

interface ProjectNotificationProps {
  project: Project;
}
const ProjectNotification = ({
  project,
}: ProjectNotificationProps) => {
  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>
  );
};

interface UserNotificationProps {
  user: User;
}

const UserNotification = ({ user }: UserNotificationProps) => {
  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>
  );
};

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>
  );
}

 

 

🍪  Dependency Inversion Principle  (의존 역전 원칙)

 

❌ BAD

import axios, { AxiosError } from "axios";
import React, { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { handleAxiosError } from "../../utils/fetching";
import { prepareFeedbackDataColumn } from "../../utils/feedback";

const FeedbackForm = () => {
  const [formData, setFormData] = useState({
    name: "",
    feedback: "",
  });

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

  const handleSubmit = async (e) => {
    try {
      e.preventDefault();
      // Perform registration logic here, e.g., send data to the server.
      console.log("Registration data:", formData);
      const data = {
        id: uuidv4(),
        fullName: formData.name,
        feedback: prepareFeedbackDataColumn(formData.feedback),
      };

      await axios.post("https://playside.io/feedback/submit", data);
    } catch (err) {
      if (err && err instanceof Error)
        console.log("Error: ", err.message);
      else if (err instanceof AxiosError) handleAxiosError(err);
    }
  };

  return (
    <div className="flex justify-center items-center min-h-screen">
      <div className="bg-white p-8 rounded-lg shadow-md w-96">
        <h2 className="text-2xl font-semibold mb-4 text-black">
          Submit Feedback
        </h2>
        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <label
              className="block text-gray-600 text-sm font-medium mb-2"
              htmlFor="name"
            >
              Your Name 🤵
            </label>
            <input
              type="text"
              id="name"
              name="name"
              value={formData.name}
              onChange={handleChange}
              className="w-full px-3 py-2 border rounded-lg outline-none focus:ring focus:ring-blue-500"
              required
            />
          </div>
          <div className="mb-4">
            <label
              className="block text-gray-600 text-sm font-medium mb-2"
              htmlFor="password"
            >
              Your Feedback 💭
            </label>
            <textarea
              id="password"
              name="feedback"
              value={formData.feedback}
              onChange={handleChange}
              className="w-full px-3 py-2 border rounded-lg outline-none focus:ring focus:ring-blue-500"
              required
            />
          </div>
          <button
            type="submit"
            className="w-full py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none"
          >
            Send Feedback 😄
          </button>
        </form>
      </div>
    </div>
  );
};

export function FeedbackBAD() {
  return <FeedbackForm />;
}

handleSubmit 부분에서는 컴포넌트 내부에 있는 API에 접근하는 것 처럼 data도 준비하고 error handling 까지 해내는 모습입니다.

컴포넌트에 너무 많은 기능을 기대하는 것은 의존 역전 원칙을 따르지 못하게 만듭니다.

만약 api가 v1 에서 v2로 변경하게 된다면, 만약 전혀 다른 프로퍼티가 있는 API로 교체되게 된다면 컴포넌트에서 처리한 error handling 도 전부 다 다시해줘야 겠죠

 

✅ GOOD

import axios, { AxiosError } from "axios";
import React, { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { handleAxiosError } from "../../../utils/fetching";
import { prepareFeedbackDataColumn } from "../../../utils/feedback";
import { FeedbackService } from "./services/feedbackService";

interface FeedbackFormProps {
  feedbackService: FeedbackService;
}

export function FeedbackForm(props: FeedbackFormProps) {
  const { feedbackService } = props;

  const [formData, setFormData] = useState({
    name: "",
    feedback: "",
  });

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

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

    //Use the Service instead of a Hardcoded implementation
    await feedbackService.submitFeedback(formData);
  };

  return (
    <div className="flex justify-center items-center min-h-screen">
      <div className="bg-white p-8 rounded-lg shadow-md w-96">
        <h2 className="text-2xl font-semibold mb-4 text-black">
          Submit Feedback
        </h2>
        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <label
              className="block text-gray-600 text-sm font-medium mb-2"
              htmlFor="name"
            >
              Your Name 🤵
            </label>
            <input
              type="text"
              id="name"
              name="name"
              value={formData.name}
              onChange={handleChange}
              className="w-full px-3 py-2 border rounded-lg outline-none focus:ring focus:ring-blue-500"
              required
            />
          </div>
          <div className="mb-4">
            <label
              className="block text-gray-600 text-sm font-medium mb-2"
              htmlFor="password"
            >
              Your Feedback 💭
            </label>
            <textarea
              id="password"
              name="feedback"
              value={formData.feedback}
              onChange={handleChange}
              className="w-full px-3 py-2 border rounded-lg outline-none focus:ring focus:ring-blue-500"
              required
            />
          </div>
          <button
            type="submit"
            className="w-full py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none"
          >
            Send Feedback 😄
          </button>
        </form>
      </div>
    </div>
  );
}
import { v4 as uuidv4 } from "uuid";
import { prepareFeedbackDataColumn } from "../../../../utils/feedback";
import axios, { AxiosError } from "axios";
import { handleAxiosError } from "../../../../utils/fetching";
import endpoints from "../endpoints";

export class FeedbackService {
  constructor(private feedbackEndpoints) {}

  async submitFeedback(feedbackData: {
    feedback: string;
    name: string;
  }) {
    try {
      const data = {
        id: uuidv4(),
        fullName: feedbackData.name,
        feedback: prepareFeedbackDataColumn(feedbackData.feedback),
      };

      await axios.post(this.feedbackEndpoints.SUBMIT, data);
    } catch (err) {
      if (err && err instanceof Error)
        console.log("Error: ", err.message);
      else if (err instanceof AxiosError) handleAxiosError(err);
    }
  }
}
import endpoints from "./endpoints";
import { FeedbackForm } from "./feedbackForm";
import { FeedbackService } from "./services/feedbackService";

export function FeedbackGOOD() {
  const feedbackServiceV1 = new FeedbackService(
    endpoints.FEEDBACK.v1
  );
  const feedbackServiceV2 = new FeedbackService(
    endpoints.FEEDBACK.v2
  );

  return <FeedbackForm feedbackService={feedbackServiceV2} />;
}

FeedbackForm에 feedbackService를 받으므로 매우 쉽게 코드를 건드리지 않고 API를 교체할 수 있습니다. 

또한 clsss 구현해 엔드포인트를 원하는 대로 변경, 수정이 가능하므로 매우 쉽게 전환이 가능합니다.

 

 

 

 

GitHub - ipenywis/react-solid: React S.O.L.I.D Principles for writing clean-code

React S.O.L.I.D Principles for writing clean-code. Contribute to ipenywis/react-solid development by creating an account on GitHub.

github.com

 

'TIL' 카테고리의 다른 글

SSR 과 CSR 눈으로 비교해 보기  (1) 2023.12.20
22/08/24 [모각코] 11일차  (0) 2022.08.26
22/08/20 [모각코] 10일차  (0) 2022.08.21
22/08/13 [모각코] 8일차  (0) 2022.08.15
22/08/10 [모각코] 7일차  (0) 2022.08.11