My Boundary As Much As I Experienced

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 2) 판매글 작성 페이지. PM님의 정책 요구사항을 모두 사수하라..~ 본문

Projects/Yanolja Bootcamp's

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 2) 판매글 작성 페이지. PM님의 정책 요구사항을 모두 사수하라..~

Bumang 2024. 3. 5. 07:22

 

퍼센트 호텔의 핵심 비즈니스 로직

퍼센트호텔은 야놀자에서 구매한 숙소 중 무료취소가 불가능한 것을 빠르게 중고거래하는 플랫폼이다.

그러므로 원래 구매가보다 비싸게 파는 것은 금지되어있다. 애초에 고급 숙소를 성수기에 예약하여 프리미엄 리셀을 하는 플랫폼이 아니기 때문에 그런 수요를 가진 유저는 타겟유저가 아니었다.

 

또한 첫 가격으로 잘 안 팔리면 특정 시간 후에 2차 가격으로 다운시킬 수 있고, 이렇게 할인률이 높아지면 메인페이지에 노출될 가능성이 오른다.

 

 

PM님의 요구한 예외 처리 조건들

이때 담당 PM님께서 예외처리할 항목들을 보내주셨는데 그 양이 꽤나 많았다.

  • '1차 가격'은 '구매가'보다 무조건 1000원 이하 낮아야 함.
  • ‘2차 가격’은 ‘1차 가격’보다 무조건 1000 원 이하 낮아야 함. (만약 1차 가격이 1000원인 경우 2차 가격은 자동으로 0원 처리)
  • ‘1차 가격’이 설정 안 된 경우 ‘2차 가격 설정 여부’ 선택 불가능
  • ‘1차 가격’ 설정하고 ‘2차 가격 설정하기’를 선택 한 경우, 2차 가격을 입력 안 했으면 결제 진행 불가
  • ‘1차 가격’ 설정하고 ‘2차 가격 설정하기’를 선택 안 한 경우, 결제 진행 가능
  • '2차 가격 설정하기'가 설정되어 있는데, ‘2차가격 하락기준 시간’이 입력되어 있지 않으면 결제 진행 불가
  • ‘2차가격 하락기준 시간’을 체크인 3시간 미만 남았을 때로 설정 불가
  • ‘2차가격 하락기준 시간’을 현재 남은 시간을 초과하여 입력 불가
  • 약관 동의를 안 했으면 모두 진행 불가
  • 이 모든 예외 조건 발생 시 에러 토스트 메시지 표출하여 사용자에게 안내

 

사실 이보다 더 많은 예외처리가 필요하거나 논의가 필요한 부분이 발생했다.

이렇게 추가적으로 발생하는 예외상황들은 개발하며 PM님께 계속 보고드렸다.

  • '1차 가격'이 입력되지 않은 상황에서 '2차 가격 설정하기' 체크 박스를 누르면 '1차 가격' 인풋에 포커스되면서 '1차 가격 먼저 입력해주세요' 토스트 표출
  • '1차 가격'이 1,000원 이하인 경우, '2차 가격'을 설정할 수 없다. (1차 가격'은 0원이 될 수 있다.)
  • 가격을 '123,456원' 처럼 입력했을 시, 56원까지 입력하게 해줘야 하나? (-> 논의 결과 input 포커스 해제 시 자동으로 반올림하도록 조정)

 

그리고 유저가 미처 예상치 못한 패턴으로 입력하는 것도 예외처리 했어야 했다.

  • 1차 가격을 100,000원, 2차 가격을 90,000원으로 해놓고, 다시 1차 가격을 다시 50,000원으로 내리면? 

충분히 발생할 수 있는 케이스다.

단순히 2차 가격 입력하는 순간에만 1차 가격 이하로 입력하게만 잡으면 위와 같은 상황은 못 잡는다.

그리고 나는 이런 상황이 발생한다면 아래처럼 처리했다.

  • 위처럼 1차 가격을 50,000원으로 내린다면 2차 가격은 49,000원으로 자동으로 내려간다. (자동으로 1,000원 낮은 가격으로 내려간다)
  • 혹여 1차 가격을 1000원 이하로 내리면 2차 가격 설정은 체크가 풀린다.

이외에도 추가적인 예외처리들이 많았지만 이만 줄이도록 하겠다.

 

 

[[구현 방법]]

더보기

 

이 모든 정책들을 예외처리 검사하고 결제를 진행할 수 있는지 체크하는 로직들이 필요했다.

 

그 양이 엄청나게 많아, 훅으로 분리 안 하고 늘어놓으면 300줄 가량되는 예외 조건 처리들이 발생했다..

코드 관심사 분리가 필수적이었다. 아래 코드에 //HOOKS라고 정리되어 있는 부분부터는

예외 처리 조건들을 모두 code splitting하여 훅으로 만든 부분이다.

 

 

여기에 쓰인 훅들의 역할을 짧게 설명해보겠다.

- usePreventLeave: 데이터들을 작성하다 실수로 나가려고 할 때 "정말 나가시겠습니까?"를 친절하게 물어봐준다.

- useReadyToSubmit: 각종 state의 변화에 따라 결제하기 버튼의 활성화 여부를 조절한다.

- useChagePage: 페이지 컴포넌트가 교체될 때마다 state의 상태에 따라 체크박스와 토글 상태를 원복시켜준다.

- usePostTransferItems: 리액트 쿼리의 useMutation을 담은 훅이다. 최종적으로 데이터를 서버로 Post한다.

- useSubmitHandler: submitHandler를 반환하는 훅이다. 모든 예외 조건 처리를 다 하고 통과하면 mutate를 실행한다.

const TransferWritingPrice = () => {
  // 현재 선택된 숙박
  const selectedItem = useSelectedItemStore((state) => state.selectedItem);

  // 유저 정보
  const userInfoQuery = useUserInfoQuery();
  const { data: userData } = userInfoQuery;

  // 1차 가격
  const [firstPrice, setFirstPrice] = useState("");
  const [is2ndChecked, setIs2ndChecked] = useState(false); // 2차 가격 설정하기 체크 여부
  // 1차 가격 HTMLElement
  const firstCheckRef = useRef(null); // 2차 가격 체크박스 ref
  const firstInputRef = useRef(null);

  // 2차 가격
  const [secondPrice, setSecondPrice] = useState("");
  const [downTimeAfter, setDownTimeAfter] = useState("");
  // 2차 가격 HTMLElement
  const secondPriceInputRef = useRef(null);
  const secondTimeInputRef = useRef(null);

  const [bank, setBank] = useState(userData?.bank ?? null);
  const [accountNumber, setAccountNumber] = useState(
    userData?.accountNumber ?? null,
  );

  // 약관 동의
  const [opt1, setOpt1] = useState(false);
  const [opt2, setOpt2] = useState(false);
  const [opt3, setOpt3] = useState(false);
  const [optFinal, setOptFinal] = useState(false);

  // 제출 가능 여부
  const [readyToSubmit, setReadyToSubmit] = useState(false);

  useEffect(() => {
    setBank(userData?.bank ?? null);
    setAccountNumber(userData?.accountNumber ?? null);
  }, [userData]);

  // HOOKS
  // 작성 중 나가면 경고하는 훅
  usePreventLeave(true);

  // state변화에 따라 제출 버튼을 활성화/비활성화 하는 훅
  useReadyToSubmit({
    setReadyToSubmit,
    firstPrice,
    opt1,
    opt2,
    opt3,
    optFinal,
    bank,
    accountNumber,
    is2ndChecked,
    secondPrice,
    downTimeAfter,
    userData,
  });

  // 계좌 등록 페이지 <-> 판매글 작성 페이지 왔다갔다 할 때
  // state상태에 따라 체크박스나 토글 풀려있는거 복구하는 훅
  const { accountSetting, setAccountSetting } = useChangePage({
    is2ndChecked,
    firstCheckRef,
  });

  // 판매글 작성 POST 리액트 쿼리 훅
  const { mutate } = usePostTransferItems({
    firstPrice,
    secondPrice,
    downTimeAfter,
    is2ndChecked,
    opt1,
    opt2,
    opt3,
    optFinal,
    bank,
    accountNumber,
    userData,
  });

  // 정책들을 모두 검토하고 마지막에 usePostTransferItems API를 POST요청하는 훅
  const [submitHandler] = useSubmitHandler({
    readyToSubmit,
    firstPrice,
    secondPrice,
    downTimeAfter,
    firstInputRef,
    secondPriceInputRef,
    secondTimeInputRef,
    is2ndChecked,
    userData,
    mutate,
    opt1,
    opt2,
    opt3,
    optFinal,
  });

  return (
    <S.Container layout>
      <TransferPricingHeader />
      {accountSetting === "none" && (
        <>
          <FirstPriceTag
            checkRef={firstCheckRef}
            inputRef={firstInputRef}
            purchasePrice={selectedItem.purchasePrice}
            inputData={firstPrice}
            onFirstPriceChange={setFirstPrice}
            is2ndChecked={is2ndChecked}
            on2ndChecked={setIs2ndChecked}
          />
          <PaymentSection
            type="first"
            price={firstPrice}
            is2ndChecked={is2ndChecked}
            title="1차 판매 체결 시 예상 정산금액"
          />
          {is2ndChecked && (
            <>
              <SecondPriceTag
                secondPriceInputRef={secondPriceInputRef}
                secondTimeInputRef={secondTimeInputRef}
                firstPrice={firstPrice}
                secondPriceData={secondPrice}
                onSecondPriceChange={setSecondPrice}
                downTimeAfter={downTimeAfter}
                onDownTimeAfterChange={setDownTimeAfter}
                remainingDays={selectedItem.remainingDays}
                remainingTimes={selectedItem.remainingTimes}
                startDate={selectedItem.startDate}
                endDate={selectedItem.endDate}
              />
              <PaymentSection
                type="second"
                price={secondPrice}
                is2ndChecked
                title="2차 판매 체결 시 예상 정산금액"
              />
            </>
          )}
          <AccountSection
            bank={bank}
            accountNumber={accountNumber}
            userInfo={userData}
            onSetAccount={setAccountSetting}
          />
          <AgreementSection
            opt1={opt1}
            opt2={opt2}
            opt3={opt3}
            optFinal={optFinal}
            setOpt1={setOpt1}
            setOpt2={setOpt2}
            setOpt3={setOpt3}
            setOptFinal={setOptFinal}
          />
          <S.ButtonSection $readyToSubmit={readyToSubmit}>
            <button onClick={submitHandler}>판매 게시물 올리기</button>
          </S.ButtonSection>
        </>
      )}
      {accountSetting === "enter" && (
        <EnterAccountInfo
          accountNumber={accountNumber}
          bank={bank}
          onSetAccountNumber={setAccountNumber}
          onSetBank={setBank}
          onSubmitAccount={setAccountSetting}
        ></EnterAccountInfo>
      )}
    </S.Container>
  );
};

export default TransferWritingPrice;

 

모든 훅이 어떻게 구현되어 있는지 보여주는 것은 힘들기 때문에 

useSubmitHandler 훅에서 제출 가능 여부를 어떻게 파악하는지만 적어보겠다.

제출 버튼을 눌렀을 때 예외처리 검사해서 통과 못하면 어떤 부분에서 통과 못한건지 토스트로 안내해준다.

import useToastConfig from "@/hooks/common/useToastConfig";
import { useSelectedItemStore } from "@/store/store";
import { ProfileData } from "@/types/profile";

interface SubmitProps {
  readyToSubmit: boolean;
  firstPrice: string;
  secondPrice: string;
  downTimeAfter: string;
  firstInputRef: React.MutableRefObject<null>;
  secondPriceInputRef: React.MutableRefObject<null>;
  secondTimeInputRef: React.MutableRefObject<null>;
  is2ndChecked: boolean;
  userData: ProfileData;
  mutate: () => void;
  opt1: boolean;
  opt2: boolean;
  opt3: boolean;
  optFinal: boolean;
}

const useSubmitHandler = ({
  readyToSubmit,
  firstPrice,
  secondPrice,
  downTimeAfter,
  firstInputRef,
  secondPriceInputRef,
  secondTimeInputRef,
  is2ndChecked,
  userData,
  mutate,
  opt1,
  opt2,
  opt3,
  optFinal,
}: SubmitProps) => {
  const { handleToast } = useToastConfig();
  const selectedItem = useSelectedItemStore((state) => state.selectedItem);

  const submitHandler = () => {
    if (!readyToSubmit) {
      const firstPriceNum = Number(firstPrice.split(",").join(""));
      const secondPriceNum = Number(secondPrice.split(",").join(""));
      const downTimeAfterNum = Number(downTimeAfter);

      if (!firstPriceNum) {
        handleToast(true, [<>1차 가격을 설정해주세요</>]);
        if (firstInputRef.current) {
          (firstInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 1차 가격이 판매가보다 높을 때
      } else if (firstPriceNum > selectedItem.purchasePrice) {
        handleToast(true, [
          <>판매가격이 구매가보다 높아요! 판매가격을 확인해주세요</>,
        ]);

        if (firstInputRef.current) {
          (firstInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 2차 가격이 1차 가격보다 높을 때
      } else if (secondPriceNum > firstPriceNum) {
        handleToast(true, [<>2차가격은 1차 가격보다 낮게 설정해주세요</>]);

        if (secondPriceInputRef.current) {
          (secondPriceInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 2차가격 인하 시간을 3시간 이하로 설정했을 때
      } else if (downTimeAfterNum && downTimeAfterNum < 3) {
        handleToast(true, [
          <>체크인 3시간 전까지만 2차 가격 설정이 가능해요</>,
        ]);

        if (secondTimeInputRef.current) {
          (secondTimeInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 2차 가격만 입력하고 2차 기준시간은 입력 안 했을 때
      } else if (is2ndChecked && secondPrice && !downTimeAfter) {
        handleToast(true, [<>2차 가격으로 내릴 시간을 입력해주세요</>]);

        if (secondTimeInputRef.current) {
          (secondTimeInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 2차 기준시간만 입력하고 2차 가격은 입력 안 했을 때
      } else if (is2ndChecked && !secondPrice && downTimeAfter) {
        handleToast(true, [<>2차 가격을 입력해주세요</>]);

        if (secondPriceInputRef.current) {
          (secondPriceInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
        // 2차 가격 설정을 체크해놓고 2차 가격과 시간 모두 입력 안 했을 때
      } else if (is2ndChecked && !secondPrice && !downTimeAfter) {
        handleToast(true, [<>2차 가격을 입력해주세요</>]);

        if (secondTimeInputRef.current) {
          (secondTimeInputRef.current as HTMLInputElement).focus();
          // + 스크롤 상단으로 올리기
        }
      }
      // 약관 동의를 다 안 했을 때
      else if (!opt1 || !opt2 || !opt3 || !optFinal) {
        handleToast(true, [<>판매 진행 약관에 동의해주세요</>]);

        // 계좌를 입력 안 한 경우
      } else if (!userData?.accountNumber) {
        handleToast(true, [<>계좌를 입력해주세요</>]);
      }

      return;
    }

    const confirmToProceed = confirm("판매 게시물을 등록하시겠어요?");
    if (confirmToProceed) {
      mutate();
    }
  };

  return [submitHandler];
};

export default useSubmitHandler;

 

 

 

추가로 인터랙션도 원하셨다.

PM님께서 2차 가격 설정을 누르면 '체크인 ~ 시간 전에', '~원으로 내릴게요' 등 텍스트가 약간의 딜레이를 보이며 숭숭 나오는 것을 요구하셨다. 또한 1차 가격의 '예상정산금액 토글'은 자동으로 collapse되어야 한다고 하셨다.

 

사실 토스에서 표방하는 1page 1function 원칙스럽게, 하나 입력하면 fade out되고 다음께 fade in되는 그런 느낌을 원하셨던 것이다.

그러나 그건 ux디자이너 분께 꽤나 부담이 됐던거 같다. 안 그래도 조건들이 많은데 이 페이지에 그 모든 인터랙션을 고려하여 시안을 짜는게 힘드시다고 말씀해주셨다. (그리고 사실 내가 제일 큰일이지..!)

 

그래서 PM님과 UX디자이너 님의 원만한 합의가 되어 나온게 위 인터랙션이다. 1차 가격일 때는 '예상 정산 금액'이 잘 보이다가 '2차 가격으로 내리기'를 체크하면 라벨이 '1차 판매 완료 시 정산 금액'으로 바뀌며 collapse되고, 2차 가격 인풋이 시간차를 두고 등장 + '2차 판매 완료 시 정산 금액'이 새로 생긴다.

 

사실 팀적으로 합의하는 것도 좋지만, '실제 유저 입장에서 이 인터랙션이 정보를 인지하는데에 효과적일까?'를 많이 생각해야 된다는 점을 잊으면 안 된다. 추후 PM분들이 유저 인터뷰를 진행했는데, '유저들이 이 부분의 인터랙션에 대해 언급을 해줄까?' 궁금했는데 아무도 특별한 코멘트를 해주진 않았다. 다들 그냥 별 생각없이 이용했는데, 그게 더 좋은 것 같기도 하다.

 

 

[[구현 방법]]

더보기

프레이머 모션을 이용해서 애니메이션을 구현했다.

나는 styled-components의 styled() 함수에 프레이머 모션의 motion element를 넣어 사용하였다.

export const Container = styled(motion.section)`
  padding: 40px 20px;

  display: flex;
  flex-direction: column;
  gap: 16px;
  background-color: white;
`;

 

 

그리고 자식들에 애니메이션을 주고싶은 컨테이너 요소에 children-container라는 클래스를 입력하고,

이것들에 opaticy가 0에서 1로 오르고, x값이 왼쪽에서 오른쪽으로 살짝 이동하는 애니메이션을 주었다.

이때 stagger(0.2)의 딜레이를 줘서 약간의 시간차를 두고 순차적으로 요소들이 mount되는 애니메이션을 주었다. 

  useEffect(() => {
    animate(
      ".children-container",
      { opacity: [0, 1], x: [-10, 0] },
      { delay: stagger(0.2) },
    );
  }, [animate]);

 

 

 

추가로 유저가 계좌 등록이 안 된 유저일 시, 계좌 등록 페이지로 리다이렉팅 시켜서 등록하게 해야했다. 돌아오면 기존 입력 사항이 모두 살아있어야 했다.

이 부분이 꽤나 구현이 빡셌다. 페이지 이탈 시 수십 개의 state가 사라질텐데, 이를 어떻게 방지하지?

 

1. 어딘가의 저장소에 저장해놓고 페이지 이동 후 복귀?

가능성 중 하나로 입력 사항을 모두 localStorage에 저장하는 손쉬운 방법을 떠올렸다.

하지만 민감한 계좌 정보나 은행 이름 등도 있는데 이를 로컬 스토리지에 저장하는 것은 좋지 않아보였다.

그리고 queryString에 담아서 보내고 돌아올 때 또 가지고 돌아오는 방법도 민감한 정보 이슈 때문에 좋지 않았다.

 

2. 전역상태관리로 처리?

이어서 전역 상태 관리로 처리할까 생각도 했지만, 결국 전역 상태 관리 라이브러리도

페이지 이동 시에 정보 유지를 위해 persist등의 기능을 쓰면 localStorage에 의존하게 된다는 점을 알게 되었다.

 

3. 라우팅을 하지 않고 기존 페이지에 머무르며 다른 페이지 컴포넌트를 불러와 끼워넣기만 하기

그러므로 나는 계좌 관리 페이지 컴포넌트를 불러와서 기존 페이지의 컴포넌트에 갈아 끼우는 방식을 선택했다.

페이지 이동이 발생하지 않으니 state가 손실될 우려도 없었다.

 

그러나 컴포넌트 교체로 이탈했다가 돌아올 때 새로운 문제점을 발견했다.

바로 약관동의, 2차 가격 설정 등의 체크박스가 모두 풀려있는 것이다.

 

페이지 컴포넌트가 unmount됐다가 다시 mount되니 원래 체크되어있던 체크박스가 모두 해제되는건 당연했다.

그러나 체크상태를 관리하는 state는 true인 상황이었다.

페이지 이탈은 하지 않았기 때문에 state는 유지되어있는데 렌더되는 화면에만 체크가 풀려있는 것이다.

 

그래서 약관동의를 미리 누르고 계좌 연결 페이지로 한 번 갔다오면

약관동의가 체크해제되어있는데도 결제가 진행되는 것처럼 보이는 상황도 발생했다..!

 

그래서, 나는 useChangePage 훅을 만들어서,

페이지 컴포넌트 교체 시마다 다시 state상태에 따라 각각의 체크박스를 다시 체크상태로 만들어주는 훅을 만들어서 해결하였다.

 

[[구현 방법]]

더보기

위에서 Hook들을 소개할 때 다룬 useChangePage 훅이다.

"none" | "enter"타입으로 페이지 전환을 마킹한다.

그리고 현재 어떤 조건인지에 따라 헤더의 텍스트를 바꾸거나, 체크박스의 체크를 체크한다.

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

import { PATH } from "@/constants/path";
import { useSelectedItemStore, useStateHeaderStore } from "@/store/store";

interface changePageProps {
  is2ndChecked: boolean;
  firstCheckRef: React.MutableRefObject<null>;
}

const useChangePage = ({ is2ndChecked, firstCheckRef }: changePageProps) => {
  const navigate = useNavigate();
  const setHeaderConfig = useStateHeaderStore((state) => state.setHeaderConfig);
  const selectedItem = useSelectedItemStore((state) => state.selectedItem);

  // 현재 페이지가 어디인지
  const [accountSetting, setAccountSetting] = useState<"none" | "enter">(
    "none",
  );

  // 페이지 전환 시 적용할 효과
  useEffect(() => {
    if (accountSetting === "none") {
      setHeaderConfig({
        title: selectedItem.hotelName,
        undo: () => {
          navigate(PATH.WRITE_TRANSFER);
        },
      });

      if (is2ndChecked && firstCheckRef.current) {
        (firstCheckRef.current as HTMLInputElement).checked = true;
      }
    }

    if (accountSetting === "enter") {
      setHeaderConfig({
        title: "계좌 연동하기",
        undo: () => {
          setAccountSetting("none");
        },
      });

      if (is2ndChecked && firstCheckRef.current) {
        (firstCheckRef.current as HTMLInputElement).checked = false;
      }
    }
    // eslint-disable-next-line
  }, [accountSetting]);

  return { accountSetting, setAccountSetting };
};

export default useChangePage;

 

 

 

그리고 결제 후 기본 계좌로 설정할지도 물어봐야 했다.

매우매우 친절한 서비스다.. 내가 UX디자이너로서 일할 땐, 이런 디테일을 잡는 것에 매우 기쁜 마음이 들었지만, 프론트 엔지니어가 되니 이걸 어떻게 구현할지 매우 머리 아픈 부분이 많았다!! 

 

[[구현 방법]]

더보기

1. 첫 페이지 진입 시에 계좌 등록 여부를 체크하여 등록이 안 되어 있으면 firstlyNoAccount라는 state를 true로 설정해두었다.

 

2. firstlyNoAccount가 true일 시, 결제 성공 페이지로 보낼 시 queryString에 이를 담아 보낸다.

 

3. 결제 완료 페이지에 도착해서 기본 계좌로 등록하라는 바텀 모달 컴포넌트를 보여준다.

 

4. useNavigate에서 2번째 인자로 state객체를 보낼 수 있다. 이를 활용하여 페이지 전환 시 안전하게 계좌 정보를 제공했다. (쿼리 스트링과 localStorage처럼 내부 저장소에 저장하는 것은 좋지 않다.)

구현에 참고가 된 블로그 글

// ... 리액트쿼리 useMutation
    onSuccess: () => {
      alert("판매 게시물이 성공적으로 등록되었습니다!");
      navigate(PATH.WRITE_TRANSFER_SUCCESS + "?FNA=" + `${firstlyNoAccount}`, {
        state: { bank, accountNumber }, // naviate의 2번째 인자로 state를 담아 보낼 수 있다.
      });
    },

 

 

 

마무리 회고

지금 돌아보니 생각 이상으로 복잡한 페이지였다.

메인 페이지 캐로셀 개발처럼 지금껏 써보지 못했던 종류의 MouseEvent나 TouchEvent를 핸들링하는 것과는 다른 복잡함이었다.

 

이미 알고 있는 state와 Ref, 커스텀 훅들을 쓰는 페이지인데, 그 갯수가 엄청나게 많았다고 해야될까..

좋은 서비스를 만드는데에 프론트엔드 개발자에게 요구되는 집요함이란 실로 대단한 것 같다.

프론트엔드는 테크 영역의 '프론트엔드'이지만, UX의 '백엔드'이기도 하다고 생각이 든다.

좋은 UX의 뒷편에는 수많은 조건 처리를 하고 있는 프론트엔드 개발자가 있는 것이다.