비즈니스 로직 분리하기

shipping.tsx

export default function Shipping() {
  const shippingAddress = useSelector(
    (state: IRootState) => state.cart.shippingAddress,
  );
  const {
    handleSubmit,
    register,
    formState: { errors },
    setValue,
  } = useForm<IAddress>();
  const router = useRouter();
  const dispatch = useDispatch();

  const [showAddressModal, setShowAddressModal] = useState(false);
  const [zipCode, setZipcode] = useState<string>("");
  const [roadAddress, setRoadAddress] = useState<string>("");

  const completeHandler = (data: Address) => {
    setZipcode(data.zonecode);
    setRoadAddress(data.roadAddress);
    setShowAddressModal(false);
  };

  useEffect(() => {
    if (!shippingAddress) return;
    setValue("fullName", shippingAddress.fullName);
    setValue("number", shippingAddress.number);
    setValue("email", shippingAddress.email);
    setValue("address", shippingAddress.address);
    setValue("detailAddress", shippingAddress.detailAddress);
    setValue("postalCode", shippingAddress.postalCode);
  }, [setValue, shippingAddress]);

  const submitHandler = ({
    fullName,
    number,
    email,
    address,
    detailAddress,
    postalCode,
  }: IAddress) => {
    dispatch(
      saveShippingAddress({
        fullName,
        number,
        email,
        address,
        detailAddress,
        postalCode,
      }),
    );
    router.push("/payment");
  };

  return (
    <div>
      {showAddressModal ? (
        <ModalWrap>
          <Modal>
            <DaumPostcode onComplete={completeHandler} />
          </Modal>
        </ModalWrap>
      ) : null}
      <CheckoutWizard activeStep={1} />
      <form onSubmit={handleSubmit(submitHandler)}>
        <h1>배송 정보</h1>
        <ul>{/* input list들 ...*/}</ul>
        <div>
          <button>Next</button>
        </div>
      </form>
    </div>
  );
}

코드만 보았을 때, 비즈니스 로직인지 아닌지 구분하는 작업이 어려웠기 때문에,

<프론트엔드 아키텍처: Business Logic의 분리>

게시글을 참고해 가며 분리하려고 해 보았는데요, 우선 뷰 로직에 해당하는 것들을 분리해 내는 작업을 해 보았습니다.

export default function Shipping() {

  const [showAddressModal, setShowAddressModal] = useState(false);

	const [zipCode, setZipcode] = useState<string>("");
	const [roadAddress, setRoadAddress] = useState<string>("");

  const completeHandler = (data: Address) => {
    //...
    setShowAddressModal(false);
  };

  const submitHandler = ({
    //...
  };

  return (
    <div>
      {showAddressModal ? (
        <ModalWrap>
          <Modal>
            <DaumPostcode onComplete={completeHandler} />
          </Modal>
        </ModalWrap>
      ) : null}
      <CheckoutWizard activeStep={1} />
      <form onSubmit={handleSubmit(submitHandler)}>
        <h1>배송 정보</h1>
        <ul>
          {/* input list들 ...*/}
        </ul>
        <div>
          <button>Next</button>
        </div>
      </form>
    </div>
  );
}

뷰 로직을 분리하고 남은 코드들 입니다.

const shippingAddress = useSelector(
  (state: IRootState) => state.cart.shippingAddress,
);
const {
  handleSubmit,
  register,
  formState: { errors },
  setValue,
} = useForm<IAddress>();
const router = useRouter();
const dispatch = useDispatch();

useEffect(() => {
  if (!shippingAddress) return;
  setValue("fullName", shippingAddress.fullName);
  setValue("number", shippingAddress.number);
  setValue("email", shippingAddress.email);
  setValue("address", shippingAddress.address);
  setValue("detailAddress", shippingAddress.detailAddress);
  setValue("postalCode", shippingAddress.postalCode);
}, [setValue, shippingAddress]);

const submitHandler = ({
  fullName,
  number,
  email,
  address,
  detailAddress,
  postalCode,
}: IAddress) => {
  dispatch(
    saveShippingAddress({
      fullName,
      number,
      email,
      address,
      detailAddress,
      postalCode,
    }),
  );
  router.push("/payment");
};

비즈니스 로직의 코드들을 하나씩 살펴보면,

  • 라이브러리들의 요소들을 꺼내오는 변수
  • 라우터 선언, dispatch 선언
  • 주소창 모달의 콜백 함수
  • 페이지 진입 시, input에 데이터를 담는 기능
  • useform의 hadleSubmit의 콜백 함수(게다가 비슷한 이름의 함수도 만들었네요)

앞서 말했던 비즈니스 로직을 … … 어지럽게 지키고 있습니다. 라이브러리 사용이 많아지면서 이상한 코드 뭉치가 되었네요. 여기서 의존성 역전 원칙을 생각해 본다면, 어떤 라이브러리로 변경하던 간에 기능이 똑같이 동작하게 해야 하고, 중간 동작은 수정을 가할 필요가 없어야 합니다. 저는 api를 fetch해서 데이터를 가져와 사용하는 것과 같은 방식으로 동작하는 것이라고 이해했어요.

  • 사용자로부터 배송 정보를 받는다(이름, 이메일, 전화번호, 주소 등)
    • 만약 입력했던 배송 정보가 이미 있었다면, 전역 상태에서 그 정보를 받아와 input에 입력한다.
const fetchInitialAddress = (array: (keyof IAddress)[]) => {
  if (!shippingAddress) return;
  array.map((x) => {
    setValue(x, shippingAddress[x]);
  });
};

useEffect(() => {
  fetchInitialAddress([
    "fullName",
    "number",
    "email",
    "address",
    "postalCode",
    "detailAddress",
  ]);
}, [shippingAddress]);

전역 상태를 받아오는 변수의 유무를 확인하고 → 그 값들을 setValue를 사용해 각 input에 넣을 수 있게 만들었습니다. 추후 useForm을 교체하게 될 상황에서는 fetchInitialAddress만 확인하면 되겠죠?

  • next 버튼 클릭 시, 사용자로부터 받은 정보를 전역 상태로 저장하고 다음 페이지로 넘긴다
const saveShippingDataWithRedux = (data: IAddress) => {
  dispatch(saveShippingAddress(data));
};

const formDataFromUseForm = (): IAddress => {
  const {
    fullName,
    number,
    email,
    address,
    postalCode,
    detailAddress,
  }: IAddress = getValues();
  return { fullName, number, email, address, postalCode, detailAddress };
};

const submitHandler = () => {
  const formData = formDataFromUseForm();
  saveShippingDataWithRedux(formData);
  router.push("/payment");
};

redux의 동작과 useForm 이 두 가지의 정보를 받아와 수행해야 하는 작업이었기 때문에, 각각 수행하는 부분을 분리하는 작업을 했습니다. 만약 useForm이라는 라이브러리가 다른 것으로 교체되더라도, 전역 상태 관리 툴에는 { fullName, number, email, address, postalCode, detailAddress }에 해당하는 값들만 넣어주기만 하면 됩니다.

submitHandler는 이제 1. 폼의 데이터들을 받아오고 2. 폼의 상태를 저장하고 3. payment를 선택하는 페이지로 사용자를 보냅니다. 구체적인 로직은 각 formDataFromUseFormsaveShippingDataWithRedux가 수행 할 것 입니다.

  • 사용자로부터 배송 정보를 받는다(이름, 이메일, 전화번호, 주소 등)
    • 주소 정보를 받을 때에는 새로 모달창을 열어 데이터를 받아 온다
const completeHandler = (data: Address) => {
	setValue("postalCode", data.zonecode);
  setValue("address", data.roadAddress);
  setShowAddressModal(false);
};

return (
	....
	<Dialog isOpen={showAddressModal}>
	    <Dialog.Dimmed />
	    <Dialog.Content>
		    <Dialog.Title>Hamster is Good</Dialog.Title>
		    <DaumPostcode onComplete={completeHandler} />
	    </Dialog.Content>
	</Dialog>
	....
)

모달창은 다른 페이지에서도 쓸 예정이기 때문에, CCP 패턴으로 구성해, 개방-폐쇄 원칙을 지키려고 해 보았습니다. onComplete에는 모달 창 close시 수행할 콜백함수를 넣었습니다. 작성하고 보니 이상한 점이 있네요.

const completeHandler = (data: Address) => {
  setValue("postalCode", data.zonecode);
  setValue("address", data.roadAddress);
  setShowAddressModal(false);
};

const fetchInitialAddress = (array: (keyof IAddress)[]) => {
  if (!shippingAddress) return;
  array.map((x) => {
    setValue(x, shippingAddress[x]);
  });
};

completeHandler에서 fetchInitialAddress에서 받아오는 객체만 다를 뿐 같은 역할을 수행합니다. 그런데 completeHandler 내부에서 fetchInitialAddress를 사용할 수가 없네요. 뭔가 분리를 잘못 했다는 겁니다

const setInputValueFromUseForm = (
  array: (keyof IAddress)[],
  data: IAddress | Address,
) => {
  if ("zonecode" in data) {
    const { zonecode, roadAddress } = data;
    setValue(array[0], zonecode);
    setValue(array[1], roadAddress);
  } else if ("fullName" in data) {
    array.map((x) => {
      setValue(x, shippingAddress[x]);
    });
  }
};

그래서 배열과 데이터를 얻어 내부에서 분기처리를 해서 데이터를 input에 넣을 수 있도록 만들었습니다. 암묵적 인자를 명시적 인자로 변경하여, 어떤 데이터를 불러와서 어떤 값을 넣는 지에 대해서도 알 수 있게 되었습니다. useForm 제외하고는 다른 외부 데이터를 사용하지도 않습니다!

// useSelector //
const shippingAddress = useSelector(
  (state: IRootState) => state.cart.shippingAddress,
);
// useSelector //
const {
  handleSubmit,
  register,
  formState: { errors },
  setValue,
  getValues,
} = useForm<IAddress>();
const router = useRouter();
const dispatch = useDispatch();
const [showAddressModal, setShowAddressModal] = useState(false);
const inputList: (keyof IAddress)[] = [
  "fullName",
  "number",
  "email",
  "address",
  "postalCode",
  "detailAddress",
];

useEffect(() => {
  setInputValueFromUseForm(inputList, shippingAddress);
}, [shippingAddress]);

const addressCompleteHandler = (data: Address) => {
  setInputValueFromUseForm(["postalCode", "address"], data);
  setShowAddressModal(false);
};

const setInputValueFromUseForm = (
  array: (keyof IAddress)[],
  data: IAddress | Address,
) => {
  if ("zonecode" in data) {
    const { zonecode, roadAddress } = data;
    setValue(array[0], zonecode);
    setValue(array[1], roadAddress);
  } else if ("fullName" in data) {
    array.map((x) => {
      setValue(x, data[x]);
    });
  }
};

const formDataFromUseForm = (): IAddress => {
  const {
    fullName,
    number,
    email,
    address,
    postalCode,
    detailAddress,
  }: IAddress = getValues();
  return { fullName, number, email, address, postalCode, detailAddress };
};

const saveShippingDataWithRedux = (data: IAddress) => {
  dispatch(saveShippingAddress(data));
};

const submitHandler = () => {
  const formData = formDataFromUseForm();
  saveShippingDataWithRedux(formData);
  router.push("/payment");
};

이렇게 각자가 수행하는 바를 명확히 알 수 있도록 함수 이름으로 명시하고, 기능을 분리하는 작업을 수행했습니다.