[꼭꼭] React Query 계층 구조 도입하기

NinjaJuunzzi·2022년 10월 4일
9

우아한테크코스

목록 보기
21/21
post-thumbnail

우아한 테크코스에는 코치와 커피 또는 식사를 함께 할 수 있는 코치와의 쿠폰 제도가 있습니다. 쿠폰을 받으면, 이를 준 사람에게 신청하여 커피나 식사를 함께 하는 방식으로 만남을 갖는 우아한 테크코스 만의 문화인데요. 우리는 위와 같이 쿠폰 문화와 유사한 Coffee Chat 문화가 있는 집단에서 집단에서 만남을 쉽게 하기 위해 꼭꼭을 개발하였습니다.

안녕하세요 꼭꼭의 프론트엔드 개발자 준찌입니다. 저희 팀은 이번 프로젝트 간 remote data를 전역화하고자 하기 위해react query를 사용하여 프론트엔드 제품의 코드들을 빚어나갔습니다. 이 과정에서 1)어떤 방식으로 커스텀 훅 기반의 계층을 분리하였고 2)계층을 분리하여 코드를 작성한 이유3)어떤 부분에서 이점을 4)어떤 부분에선 문제점을 느끼게 되었는 지 공유하고자 이 포스팅을 작성하게 되었습니다.

1) 어떻게 계층을 분리하였나요


  • useQuery, useMutation, useQueryClient를 사용하는 쿼리 훅 계층

  • 쿼리 훅 계층의 반환 값으로 작성되는 비즈니스 로직 훅 계층

  • 비즈니스 로직 훅 계층에서 실제 비즈니스 로직 훅을 받아 핸들러 코드를 작성하여 엘리먼트에 바인딩하는 컴포넌트 계층

이렇게 3개의 계층으로 구분해보았습니다. 계층 별 역할에 대해서 간단히 소개해보겠습니다.

쿼리 훅 계층

이 계층은 유일하게 react-query의 기술들을 직접 호출하고 사용합니다. 그렇기 때문에 이 계층에 query key가 은닉화 되어 있고, 다른 계층에서는 이 계층에 묶인 query key를 사용할 수 없습니다. 이 훅 계층은 도메인 영역 별로 존재합니다. 다음은 코드 예시입니다.

// @queries/coupon.ts

const QUERY_KEY = {
  /** MAIN KEY */
  coupon: 'coupon',
  reservationList: 'reservationList',
  couponList: 'couponList',
  couponListByStatus: 'couponListByStatus',

  /** SUB KEY */
  sent: 'sent',
  received: 'received',

  REQUESTED: 'REQUESTED',
  READY: 'READY',
  ACCEPTED: 'ACCEPTED',
  FINISHED: 'FINISHED',
};

export const useFetchCoupon = (id: number) => {
  const { data } = useQuery([QUERY_KEY.coupon, id], () => getCoupon(id), {
    staleTime: 10000,
  });

  return {
    coupon: data,
  };
};

/*...*/

export const useCreateCouponMutation = () => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();

  return useMutation(createCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.couponList, QUERY_KEY.sent]);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

// ...

흔히 작성되는 react-query 기반의 커스텀 훅들이 작성되는 계층이라 생각하시면 이해하기 편하실 것입니다. 특별한 점은 query key를 은닉화 한다는 점, queryClient 객체를 사용하는 곳은 이 곳 밖에 없다는 점이라 말할 수 있습니다!

정리하자면 이 계층은 queryClient 객체를 Context에서 꺼내와 사용할 수 있습니다. 따라서 쿼리 데이터의 갱신 삭제 덮어쓰기 기능을 수행할 수 있습니다. 또 도메인 별로 존재하는 query key (query data의 index가 되는)는 각 도메인 별 이 계층에 묶여 있기 때문에 다른 컴포넌트 코드, 훅 코드에서 참조가 불가능합니다.

비즈니스 로직 훅 계층

쿼리 훅 계층으로 부터 반환되는 값으로 작성되는 비즈니스 로직들이 포함되는 영역입니다. 이 계층 역시 도메인 별로 존재하며 쿼리 훅 계층으로 부터 remote data 혹은 mutation 객체를 받아 컴포넌트 혹은 컴포넌트 커스텀 훅에게 비즈니스 로직을 전달하는 역할을 수행합니다. 다음은 코드 예시입니다.

export const useChangeCouponStatus = ({ couponId }: { couponId: number }) => {
  const { displayMessage } = useToast();

  // 쿼리 훅 계층의 커스텀 훅이 사용됩니다.
  const changeStatusMutate = useChangeCouponStatusMutation(couponId);

  const cancelCoupon = () => {
    return changeStatusMutate.mutateAsync(
      { couponId, body: { couponEvent: 'CANCEL' } },
      {
        onSuccess() {
          displayMessage('쿠폰 사용을 취소했어요', false);
        },
      }
    );
  };

  const requestCoupon = ({
    meetingDate,
    meetingMessage,
  }: {
    meetingDate: YYYYMMDD;
    meetingMessage: string;
  }) => {
    return changeStatusMutate.mutateAsync(
      { couponId, body: { couponEvent: 'REQUEST', meetingDate, meetingMessage } },
      {
        onSuccess() {
          displayMessage('쿠폰 사용을 요청했어요', false);
        },
      }
    );
  };

  const finishCoupon = () => {
    return changeStatusMutate.mutateAsync(
      { couponId, body: { couponEvent: 'FINISH' } },
      {
        onSuccess() {
          displayMessage('쿠폰 사용을 완료했어요', false);
        },
      }
    );
  };

  const acceptCoupon = ({ meetingMessage }: { meetingMessage: string }) => {
    return changeStatusMutate.mutateAsync(
      { couponId, body: { couponEvent: 'ACCEPT', meetingMessage } },
      {
        onSuccess() {
          displayMessage('쿠폰 사용을 승인했어요', false);
        },
      }
    );
  };

  const declineCoupon = ({ meetingMessage }: { meetingMessage: string }) => {
    return changeStatusMutate.mutateAsync(
      { couponId, body: { couponEvent: 'DECLINE', meetingMessage } },
      {
        onSuccess() {
          displayMessage('쿠폰 사용을 거절했어요', false);
        },
      }
    );
  };

  return {
    cancelCoupon,
    requestCoupon,
    finishCoupon,
    acceptCoupon,
    declineCoupon,
  };
};

컴포넌트 계층

이 계층에는 엔티티가 두 개 존재할 수 있습니다. 컴포넌트 혹은 컴포넌트의 기능이 분리된 커스텀 훅. 비교적 기능이 단순하여 커스텀 훅으로 분리되지 않았다면, 컴포넌트 단에서 비즈니스 로직 훅을 호출하게 되지만 컴포넌트 자체의 기능이 복잡한 경우 UI 훅으로 분리해두었기에 실제 비즈니스 로직 훅 계층의 함수들은 UI 훅이 받아보게 됩니다.

이 계층은 비즈니스 로직 훅 계층으로 부터 비즈니스 로직을 전달받아 핸들러 코드를 만들어 컴포넌트에게 부착하는 역할을 수행합니다.

부가적으로 useQuery 기반의 커스텀 훅을 호출하기도 합니다. (기존에는 비즈니스 로직 훅 계층을 거쳐 데이터가 전달되었다면, 현재는 그 니즈가 너무 적어 직접 쿼리 훅 계층을 호출할 수 있게 해두었습니다.) 다음은 코드 예시입니다.

const CouponDeclinePage = () => {
  const { declineCoupon } = useChangeCouponStatus({
    couponId: Number(couponId),
  });
  
  /* ... */

  const onClickDeclineButton = async () => {
    if (!window.confirm('쿠폰 사용 요청을 거절하시겠어요?')) {
      return;
    }

    await declineCoupon({ meetingMessage });

    if (isSent) {
      navigate(PATH.SENT_COUPON_LIST, { replace: true });
    } else {
      navigate(PATH.RECEIVED_COUPON_LIST, { replace: true });
    }
  };

  return (
    <PageTemplate.ExtendedStyleHeader title='쿠폰 거절하기'>
      <Styled.Root>
        <Styled.Top>
          <Styled.ProfileImage src={member.imageUrl} alt='프로필' width={51} height={51} />
          <Styled.SummaryMessage>
            <strong>
              {member.nickname} {isSent ? '님에게' : '님이'} 보낸
            </strong>
            &nbsp;
            {couponTypeTextMapper[couponType]} 쿠폰
          </Styled.SummaryMessage>
        </Styled.Top>
        <Styled.Main>
          <Styled.SectionTitle>
            {generateDateKR(meetingDate)}에 만남이 어려우신가요?
          </Styled.SectionTitle>
          <Styled.Description>메시지를 작성해보세요. (선택)</Styled.Description>

          <Styled.TextareaContainer>
            <Styled.MessageTextareaContainer>
              <Styled.MessageTextarea
                id='message-textarea'
                placeholder='시간, 장소 등 원하는 메시지를 보내보세요!'
                value={meetingMessage}
                onChange={onChangeMeetingMessage}
              />
              <Styled.MessageLength>{meetingMessage.length} / 200</Styled.MessageLength>
            </Styled.MessageTextareaContainer>
          </Styled.TextareaContainer>

          <Position position='fixed' bottom='0' right='0' css={Styled.ExtendedPosition}>
            <Button onClick={onClickDeclineButton} css={Styled.ExtendedButton}>
              약속 거절하기
            </Button>
          </Position>
        </Styled.Main>
      </Styled.Root>
    </PageTemplate.ExtendedStyleHeader>
  );
};

저희는 이렇게 세 개의 계층에 역할을 부여하였고, 훅 간의 호출을 단방향으로 이어질 수 있게끔 아키텍처링을 시도하였습니다. 또 queryClient의 사용처를 한 곳으로 제한하기도 하였죠. 이렇게 자세한 계층 구조를 사용하면서 다양한 이점, 문제점들을 만나보았는데요. 그 전에 이유부터 설명하고 넘어가보겠습니다.

2) 계층을 분리하여 코드를 작성한 이유


저희 팀은 미래를 상상하고 코드를 짜는 사람들이 아닙니다. 이렇게 계층을 명확히 구분하고 역할을 부여한데에는 다 이유가 있다는 뜻이지요. TL;DR이니 요약 부터 확인하고 가겠습니다.

  1. (계층 구조가 없다보니) 어떤 기능은 커스텀 훅으로 분리되지 않았다. 기능을 트리거하는 코드가 중복되고 있었다. 뿐만 아니라 코드 일관성이 떨어지다.

  2. (queryClientquery key를 아무곳에서나 사용하다보니) 디버깅 및 유지보수가 어려워지다.

기능을 트리거하는 코드가 중복되고 있었다.

각 기능을 트리거하는 코드, 즉 비즈니스 로직들은 성공 처리 및 실패 처리를 갖게 됩니다. 그리고 이러한 것들은 공유됩니다. 예를 들어, 쿠폰을 생성하는 비즈니스 로직을 수행시키면 Toast를 띄우는 기능, Loading을 띄우는 기능, CouponList의 쿼리 데이터를 갱신하는 기능이 성공 로직으로 포함되어야 하고, 실패 로직에 역시 마찬가지로 공유되는 선처리, 후처리 기능들이 있게 됩니다. 이 코드들을 커스텀 훅으로 분리하지 않게 되면 코드는 분명 같은 의도를 내포하는 기능을 수행함에도 중복되게 됩니다.

저희는 최초 커스텀 훅으로 분리한다는 규칙 및 어떻게 분리할 지에 대한 규칙을 가져가지 않았기 때문에 어떤 코드에서는 컴포넌트가 직접 비즈니스 로직을 만들어 바인딩하고, 어떤 코드에서는 잘 분리되어 호출하여 기능을 부착하기만 하는 서로 다른 코드들이 프로덕트 내에서 작성되고 있었습니다.

이러다 보니 기능 코드의 중복이 발생하였고, 코드 일관성이 떨어지게되었죠. 이를 없애기 위해 useQuery,useMutation 코드들 및 이 코드들을 사용하여 작성되는 business 로직들은 커스텀 훅으로 분리한다는 규칙을 가져가게 되었습니다.

디버깅 및 유지보수가 어려워지다.

queryClient는 정말 많은 기능을 가지고 있습니다. query key만 알고 있으면 쿼리데이터를 삭제할수도, 무효화할수도 또 갱신할수도 있습니다. 게다가 이 queryClientreact-query의 전신이라 Provider로 인해 컴포넌트 단으로 주입되고, 하위 컴포넌트 및 훅에서 useQueryClient만 호출하면 객체를 받아낼 수 있는 그러한 형태이기도 합니다.

즉, 정리하자면 전역의 데이터를 조작할 수 있는 녀석이 프론트엔드 코드 전역에서 사용될 수 있다는 것입니다. 실제로 알림함 기능을 구현하는데 있어, 동작을 이해하는데 힘들어 유지보수가 힘들어지기도 하였죠. (queryClient가 쿼리 데이터를 무효화하는 코드가 비즈니스 로직이 아닌 컴포넌트 단에 작성됨 => 어디서 쿼리 데이터를 무효화하는 지 찾기 힘들다)

저희는 이 문제점을 파악한 이 후 전역에 queryClient 코드가 퍼지면 이 후 유지보수 및 기능 사항 디버깅에 있어 힘들어질 것이라 판단하였고, 호출할 수 있는 곳을 제한하고자 하였습니다. 또한 query key 역시 이를 알고있음으로서 할 수 있는 것들이 많아지기에 쿼리 훅 계층에 은닉화 시켜 다른 곳에서는 사용되지 못하도록 제한하기도 하였죠.

저희는 이처럼 크게 두 가지 이유로 커스텀 훅을 분리하는 규칙 및 계층 구조를 도입하고자 하였습니다.

3) 어떤 부분에서 이점을


크게 네 가지 정도의 이점을 얻을 수 있었어요.

  • 개발 플로우가 고정되다

  • 유지보수와 디버깅이 수월하다.

  • 일관성이 맞춰지다 보니 코드 리뷰 과정이 수월하다.

  • API 명세 변경에 점진적으로 대응할 수 있었다.

개발 플로우가 고정되다.

저희는 remote data를 사용하거나 수정하는 코드 단에서 계층 구조를 가져가고 있습니다. axios를 사용하여 구현되는 API Caller 단 까지 포함하면 총 4개의 계층 구조인데, 이렇게 계층 구조가 갖추어지고 계층들이 단방향으로 호출되는 단순한 플로우를 가져가다보니 새로운 도메인 영역이 추가될 때 개발하기가 훨씬 수월했습니다. 이번에 새로운 도메인 영역(미등록 쿠폰 영역)에 대한 기능을 작성할 때 저희는 다음과 같은 개발 플로우로 빠르게 개발할 수 있었어요. (코드를 자세히 보지 않아도 됩니다.)

  1. API 명세를 보고 type을 작성한다.
import { COUPON_ENG_TYPE, COUPON_HASHTAGS } from '@/types/coupon/client';

import { UNREGISTERED_COUPON_STATUS, UnregisteredCoupon } from './client';

export interface UnregisteredCouponListByStatusRequest {
  type: UNREGISTERED_COUPON_STATUS;
}

export interface UnregisteredCouponListResponse {
  data: UnregisteredCoupon[];
}

export type UnregisteredCouponResponse = UnregisteredCoupon;

export interface RegisterUnregisteredCouponRequest {
  couponCode: string;
}

export interface CreateUnregisteredCouponRequest {
  quantity: number;
  couponTag: COUPON_HASHTAGS;
  couponMessage: string;
  couponType: COUPON_ENG_TYPE;
}
  1. axios 기반의 API Caller를 작성한다.
export const getUnregisteredCouponListByStatus = async ({
  type,
}: UnregisteredCouponListByStatusRequest) => {
  const { data } = await client.get<UnregisteredCouponListResponse>(
    `/lazy-coupons/status?type=${type}`
  );

  return data;
};

export const getUnregisteredCouponById = async (id: number) => {
  const { data } = await client.get<UnregisteredCouponResponse>(`/lazy-coupons/${id}`);

  return data;
};

export const createUnregisteredCoupon = async (body: CreateUnregisteredCouponRequest) => {
  const { data } = await client.post<UnregisteredCouponListResponse>('/lazy-coupons', body);

  return data;
};

export const getUnregisteredCouponByCode = async (couponCode: string) => {
  const { data } = await client.get<UnregisteredCouponResponse>(
    `/lazy-coupons/code?couponCode=${couponCode}`
  );

  return data;
};

export const registerUnregisteredCoupon = async (body: RegisterUnregisteredCouponRequest) => {
  const { data } = await client.post<UnregisteredCouponResponse>('/lazy-coupons/register', body);

  return data;
};

export const deleteUnregisteredCoupon = async (id: number) => {
  const { data } = await client.delete<UnregisteredCouponResponse>(`/lazy-coupons/${id}`);

  return data;
};
  1. 제일 하위 계층인 쿼리 훅 계층을 작성한다.
const QUERY_KEY = {
  unregisteredCoupon: 'unregisteredCoupon',
  unregisteredCouponListByStatus: 'unregisteredCouponListByStatus',

  ISSUED: 'ISSUED',
  REGISTERED: 'REGISTERED',
  EXPIRED: 'EXPIRED',
};

export const useFetchUnregisteredCouponById = (id: number) => {
  const { data, isLoading } = useQuery(
    [QUERY_KEY.unregisteredCoupon, id],
    () => getUnregisteredCouponById(id),
    {
      staleTime: 10000,
    }
  );

  return {
    unregisteredCoupon: data,
    isLoading,
  };
};

export const useFetchUnregisteredCouponByCode = (couponCode: string) => {
  const { data, isLoading } = useQuery(
    [QUERY_KEY.unregisteredCoupon, couponCode],
    () => getUnregisteredCouponByCode(couponCode),
    {
      staleTime: 10000,
    }
  );

  return {
    unregisteredCoupon: data,
    isLoading,
  };
};

export const useFetchUnregisteredCouponListByStatus = (
  body: UnregisteredCouponListByStatusRequest
) => {
  const { data, isLoading } = useQuery(
    [QUERY_KEY.unregisteredCouponListByStatus, body.type],
    () => getUnregisteredCouponListByStatus(body),
    {
      staleTime: 10000,
    }
  );

  return {
    unregisteredCouponListByStatus: data?.data ?? [],
    isLoading,
  };
};

/** Mutation */

export const useCreateUnregisteredCouponMutation = () => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();

  return useMutation(createUnregisteredCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCouponListByStatus, QUERY_KEY.ISSUED]);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

export const useRegisterUnregisteredCouponMutation = (id: number) => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();
  const { invalidateReceivedCouponList } = useCouponInvalidationOnRegisterUnregisteredCoupon();

  return useMutation(registerUnregisteredCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCoupon, id]);
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCouponListByStatus, QUERY_KEY.ISSUED]);

      invalidateReceivedCouponList();
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

export const useDeleteUnregisteredCouponMutation = (id: number) => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();

  return useMutation(deleteUnregisteredCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCouponListByStatus, QUERY_KEY.ISSUED]);
      queryClient.removeQueries([QUERY_KEY.unregisteredCoupon, id]);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};
  1. 그 다음 비즈니스 로직 훅 계층을 작성한다.
export const useCreateUnregisteredCoupon = () => {
  const { displayMessage } = useToast();

  const { mutateAsync } = useCreateUnregisteredCouponMutation();

  const createUnregisteredCoupon = async (body: CreateUnregisteredCouponRequest) => {
    const { data } = await mutateAsync(body, {
      onSuccess() {
        displayMessage('쿠폰을 생성했어요', false);
      },
    });

    return data;
  };

  return { createUnregisteredCoupon };
};

export const useRegisterUnregisteredCoupon = (id: number) => {
  const { displayMessage } = useToast();
  const { mutateAsync } = useRegisterUnregisteredCouponMutation(id);

  const registerUnregisteredCoupon = (body: RegisterUnregisteredCouponRequest) => {
    return mutateAsync(body, {
      onSuccess() {
        displayMessage('쿠폰이 등록되었습니다.', false);
      },
    });
  };

  return {
    registerUnregisteredCoupon,
  };
};

export const useDeleteUnregisteredCoupon = (id: number) => {
  const { displayMessage } = useToast();
  const { mutateAsync } = useDeleteUnregisteredCouponMutation(id);

  const deleteUnregisteredCoupon = () => {
    return mutateAsync(id, {
      onSuccess() {
        displayMessage('쿠폰이 삭제되었습니다.', false);
      },
    });
  };

  return {
    deleteUnregisteredCoupon,
  };
};
  1. 컴포넌트 단을 작성 후 비즈니스 로직 기반의 이벤트 핸들러를 바인딩한다.
const UnregisteredCouponDetail = () => {
  const navigate = useNavigate();

  const { unregisteredCouponId } = useParams();

  const { unregisteredCoupon } = useFetchUnregisteredCouponById(Number(unregisteredCouponId));

  const { deleteUnregisteredCoupon } = useDeleteUnregisteredCoupon(Number(unregisteredCouponId));

  if (!unregisteredCoupon) {
    return <NotFoundPage />;
  }

  const { couponMessage, createdTime } = unregisteredCoupon;

  const onClickDeleteButton = async () => {
    if (!window.confirm('쿠폰을 삭제하시겠습니까?')) {
      return;
    }

    await deleteUnregisteredCoupon();

    navigate(PATH.UNREGISTERED_COUPON_LIST, { replace: true });

    unregisteredFilterOptionsSessionStorage.set('미등록');
  };

  return (
    <PageTemplate.ExtendedStyleHeader title='미등록 쿠폰'>
      <Styled.Root>
        <Styled.Top>
          <UnregisteredCouponExpiredTime createdTime={createdTime} />
        </Styled.Top>
        <Styled.Main>
          <Styled.CouponInner>
            <UnregisteredCouponItem {...unregisteredCoupon} />
          </Styled.CouponInner>
          <Styled.SubSection>
            <Styled.SubSectionTitle>쿠폰 메시지</Styled.SubSectionTitle>
            <Styled.DescriptionContainer>{couponMessage}</Styled.DescriptionContainer>
          </Styled.SubSection>
          <Styled.FinishButtonInner>
            <button onClick={onClickDeleteButton}>쿠폰을 삭제하시겠습니까?</button>
          </Styled.FinishButtonInner>
        </Styled.Main>
      </Styled.Root>
    </PageTemplate.ExtendedStyleHeader>
  );
};

유지보수와 디버깅이 수월하다.

queryClient로 파생되는 리액트 쿼리 기술들의 사용처를 한 곳에 격리시키다 보니 사이드 이펙트를 예측하기 쉬워졌습니다. 저희 구조에서 쿼리 데이터가 갱신되거나 삭제되는 등의 기능 사항을 점검하고 있다면 보아야하는 곳은 쿼리 훅 계층의 파일이게 됩니다. 매우 디버깅이 쉬워지고, 사이드 이펙트를 예측할 수 있게되었습니다.

// 미등록 쿠폰의 쿼리를 무효화하는 코드는 어디 작성되어 있지를 고민할 필요가 없게됨

export const useRegisterUnregisteredCouponMutation = (id: number) => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();
  const { invalidateReceivedCouponList } = useCouponInvalidationOnRegisterUnregisteredCoupon();

  return useMutation(registerUnregisteredCoupon, {
    onSuccess() {
      // 여기 있음. 언제나 queryClient를 사용하는 코드는 쿼리 훅 계층에 있다.
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCoupon, id]);
      queryClient.invalidateQueries([QUERY_KEY.unregisteredCouponListByStatus, QUERY_KEY.ISSUED]);

      invalidateReceivedCouponList();
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

원래의 기능 리뷰보다 수월히 진행

확실히 훅을 어떻게 분리해야하고, 또 각 훅에는 어떤 코드들이 작성되어야 하는지가 명확해지다 보니 서로가 작성하는 코드가 유사해지고 이전에 코드 리뷰 시간에 진행되었던 이건 왜 훅으로 분리안하셨나요?, 이건 왜 훅으로 분리하셨나요와 같은 토론이 필요없게되었습니다.

API 명세 변경에 대응하기가 너무나도 쉬웠습니다. 점진적으로 대응할 수 있게 되었습니다.

API의 명세가 변경되면, 보통의 경우 추적해야하는 곳이 굉장히 많게 됩니다. 만약 비즈니스 로직이 컴포넌트에서 이루어지고 있었다면 기능을 수정하는데도 컴포넌트 코드들을 뒤져야하고 이를 수정해야했겠지요. 하지만 우리는 훅 분리가 명확히 되어있고 계층 구조가 잡혀있는 형태였습니다. 따라서 API 명세 변경에 수월하게 대응할 수 있었습니다.

types 수정 → fetcher 단 수정 → 쿼리 훅 계층 → 비즈니스 로직 훅 계층 → Component 계층 수정

이렇게 크게 4가지 정도의 이점을 맛본 것 같아요. 하지만 몇 가지 문제점을 가지기도 하였습니다.

4) 어떤 부분에서 문제점을


  • 비즈니스 로직 계층은 불필요한 계층이지 않을까 생각해보게 되다.

  • useMutation 훅은 필요하지 않다.

비즈니스 로직 계층은 사실 쿼리 훅 계층에 존재하면 된다.

그 당시에는 쿼리 훅 계층을 사용하는 비즈니스 로직이 1:1 대응이 아니라 1:N일 것이라 생각했습니다. 예를 들어 useCreateCouponMutation이라는 쿼리 훅 계층의 훅은 여러 개의 비즈니스 로직 훅을 낳을 수도 있다라고 판단했었습니다. 하지만 프로젝트가 진행되면 진행될 수록 그러한 케이스들은 발견되지 않았고 1:1 대응으로 맞춰지게 되더군요!

따라서 다음과 같이 존재하면 오히려 Co-Location한 코드가 되어 유지보수나 디버깅에 유리하지 않을까 생각하게 되었습니다.

export const useCreateCouponMutation = () => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();

  const mutation = useMutation(createCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.couponList, QUERY_KEY.sent]);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
  
  const createCoupon = async (body: CreateCouponRequest) => {
    const { data } = await createCouponMutate.mutateAsync(body, {
      onSuccess() {
        displayMessage('쿠폰을 생성했어요', false);
      },
    });

    return data;
  };
  // useMutation 훅을 밖으로 내보내지 않아도, 비즈니스 로직 함수 작성해서 내보내면 된다.
  return {createCoupon};
};

비즈니스 로직에 대한 실패, 성공 로직이 분산되지 않는다는 점, 복잡도가 낮아진다는 점에서 효과적일 것이라 생각하게 되었습니다.

useMutation은 필요하지 않다.

useMutation 훅은 useSWR의 기술과는 다르게 쿼리 데이터와 관계가 없습니다. 즉, 일반 Ajax 요청을 보내는 axios 코드와 다를 바 없습니다. 그 당시 사용했던 이유는 onSuccess, onFailure 등의 옵션이 존재하여 성공 및 실패 처리가 더 쉽고 간편하게 작성될 것이라 생각하였기 때문인데, 사실 비즈니스 로직 함수를 하나 만들어두면 이 또한 쉽게 처리할 수 있는 영역이라 생각되어 필요하지 않다고 생각되게 되었습니다.


// ❌ useMutation에 대해 이해해야하고,
// mutate를 사용하는 경우 성공 처리, 실패 처리를 할 수 없게되어 불편한 점이 존재한다.
export const useCreateCouponMutation = () => {
  const queryClient = useQueryClient();
  const { showLoading, hideLoading } = useLoading();

  return useMutation(createCoupon, {
    onSuccess() {
      queryClient.invalidateQueries([QUERY_KEY.couponList, QUERY_KEY.sent]);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

// ✅ useMutation을 사용하지 않는게 더 간편할 지 모르겠다.
const useCreateCoupon = () => {
  const createCoupon = async () => {
    try {
      // onMutate
      await ajax();
      // onSuccess에 들어가던 로직을 이곳으로
    } catch (error) {
      // onError에 들어가던 로직을 이곳으로
    } finally {
      // onSettled
    }
  };

  return createCoupon;
};

정리

저희는 몇 가지 문제점을 만나게 되어 react query 기술을 사용하는 코드들을 훅으로 분리하였고, 이 훅들을 컴포넌트까지 단방향으로 호출될 수 있게 구분하였습니다. 또 react query의 기술 중 전역에 퍼질 수 있어 위험한 코드들은 사용처를 제한하여 이 후 디버깅이나 유지보수 과정을 신경쓰기도 하였고, 나아가 이 방식의 장점 및 단점에 대해서도 많이 고민해봤습니다.

내용이 많이 복잡해 죄송합니다. 재미있게 봐주셨다면 감사하구요 !!

더 많은 기술 탐구는 이 곳에서 확인하세요.

제가 만든 react query playground입니다. 삽질 저장소이지요. 삽질을 경험하고 싶지 않다면 확인해주세요!

profile
Frontend Ninja

1개의 댓글

comment-user-thumbnail
2023년 3월 5일

프로젝틍테 query 도입하려고 찾고있던 내용인데, 잘 정리되어 있어서 잘보고 갑니다!! 감사합니다 : )

답글 달기