Skip to content

트러블슈팅

A edited this page Jul 23, 2022 · 5 revisions

Issue 1

React Query 사용시 데이터 업데이트가 실시간으로 되지 않는 문제

📁 주문 데이터를 mutation을 했을 때, 수정한 데이터가 곧바로 변경되지 않는 문제가 있었습니다.

  • 사실 수집
    • 예제 코드에서는 mutation을 했을 때, useQueryclient를 사용해서 데이터를 invalidate를 한다.
const queryClient = useQueryClient()

const { mutate } = useMutate(dataFetch, {
	onSuccess: ()=>{
		queryClient.invalidateQueries('key')

	}
})
  • 원인 파악
    • queryClient.invalidateQueries('key' 입력시 key값이 정확하지 않았을 때 이러한 문제가 발생한다.
    • mutate이후에 변경된 두개 이상의 query의 변수가 useEffect에서 의존성에 전부 들어가 있지 않았다.
  • 해결 방법
    • invalidate하고자 하는 query의 키 값을 정확하게 입력해줍니다.
    • useEffect에 의존성 값을 전부 다 넣습니다.
const { isSuccess:isGetProductSuccess, isRefetching:isGetProductRefetching } = useQuery('getProduct')
const { isSuccess:isOrderSuccess, isRefetching:isOrderRefetching } = useQuery('getOrder')
const { mutate } = useMutate(dataFetch, {
	onSuccess:()=>{
		queryClient.invalidateQueries('getOrder')
		queryClient.invalidateQueries('getProduct')
  }
})
useEffect(()=>{
	if(isGetProductSuccess && isOrderSuccess && !isGetProductRefetching && isOrderRefetching){
		// refetching 성공시 동작시켜야할 코드
  }	
}, [isGetProductSuccess, isGetProductRefetching, isOrderSuccess, isOrderRefetching])

📁 상품 옵션 수정시에 옵션을 추가를 위해서 input field를 추가할 때, 먼저 옵션을 DB에 추가하고 추가 된 데이터를 form에 반영하려고 했지만 invalidate된 이후 다시 불러온 데이터를 form에서 의도한 대로 반영하지 못했습니다.

  • 원인 파악

    • useState가 비동기로 동작하여 상태가 곧바로 적용되지 않는 문제와 같이 react hook form에서 변경된 데이터를 곧바로 반영하지 못했습니다.
  • 해결 방법

    • 옵션 추가를 DB에 먼저 등록하고 refetcing한 데이터를 가져와 form에 적용하는 방법을 포기했습니다.
    • 사용자가 상품 수정 버튼을 마지막에 눌렀을 때, 데이터를 서버에 한번에 등록하면서 그 뒤에 query를 invalidate하는 방법으로 문제를 해결하였습니다.
  • 마무리

    • product의 mutate가 성공한 다음에 기존에 있는 option의 수정을 처리하고 그 다음에 등록되지 않은 option을 mutate해야합니다. 코드가 너무 복잡하게 서로 얽혀있어서 나중에 시간이 지난 다음 코드를 보면 코드를 한참 봐야합니다.
    • option mutate 이후 isUpdateProductSuccess, isAddOptionSuccess 이 두 값이 true일 경우 useEffect에서 상품에 대한 query를 invalidate합니다.
    • 만약 둘 중에 하나라도 실패하면 사용자가 수정하거나 새로 등록한 옵션값이 정상적으로 출력되지 않는 문제가 발생합니다.
    const { mutate: updateProductMutation } = useUpdateProductMutation(
        graphqlReqeustClient(accessToken),
        {
          onSuccess: () => {
            queryClient.invalidateQueries("getProducts");
          }
        }
      );
    
      const { isSuccess: isAddOptionSuccess, mutate: addProductOptionMutate } =
        useAddProductOptionsMutation(graphqlReqeustClient(accessToken));
    
      const {
        isSuccess: isUpdateProductSuccess,
        mutate: updateProductOptionsMutate
      } = useUpdateProductOptionsMutation(graphqlReqeustClient(accessToken));
    
      const selectUpdateItemsSubmitHandler = handleSubmit((data) => {
        const options = optionValue("options");
    
        const addOptions = options
          .filter((value) => value.optionId === undefined)
          .map((item) => ({ productId: selectUpdateProduct.id, name: item.name }));
    
        const updateOptions = options.filter(
          (value) => value.optionId !== undefined
        );
    
        if (options.length !== 0) {
          const updateData = {
            productId: selectUpdateProduct.id,
            name: data.name,
            price: Number(data.price),
            imageUrl: (data.imageUrl as string) || undefined,
            description: (data.description as string) || undefined
          };
          updateProductMutation(
            { products: updateData },
            {
              onSuccess: () => {
                updateProductOptionsMutate({ option: updateOptions });
                addProductOptionMutate({ option: addOptions });
              }
            }
          );
          return;
        }
    
        optionSetError("options", {
          message: "반드시 하나 이상의 옵션이 있어야합니다."
        });
      });
    
      useEffect(() => {
        if (selectUpdateProduct.options) {
          const setOptions = selectUpdateProduct.options.map((value) => ({
            optionId: value.id,
            name: value.name
          }));
    
          setOptionValue("options", setOptions);
        }
      }, []);useEffect(() => {
        if (isAddOptionSuccess && isUpdateProductSuccess) {
          setIsModal(false);
          setSelectUpdateProduct(updateDefault);
          queryClient.invalidateQueries("getProducts");
        }
      }, [isAddOptionSuccess, isUpdateProductSuccess]);

📁 회원가입 시 사용자 정보만 입력 후 가입을 진행했는데, 사용자 정보에 사업체 정보를 추가해 등록하는 기능을 추가로 구현하려고 할 때 정보가 등록되지 않는 문제가 있었습니다.

  • 사실 수집

    • 회원가입 mutation(useSignupMutation) 을 진행하면 accessToken 을 발행하는데 이를 local storage와 전역으로 관리되는 userState 에 Recoil로 저장합니다.
    • 위에서 받은 accessToken 이 있어야 user 정보를 저장하는 query 를 호출할 수 있고, 성공시 user id, name, emailuserState 에 저장합니다.
    • 두 번째의 user 정보가 있어야 가게 등록 mutation(useAddStoreMutation)이 가능합니다.
    • 유저는 회원가입만 하거나 또는 회원가입/사업체 정보 동시 등록 둘 중 하나를 선택할 수 있어야 하므로 선택 값을 state에 저장합니다.
    • 위 기능 구현에 필요한 react-query 사용 방법
      • query는 컴포넌트가 mount 되면서 자동으로 호출되나 {enabled: false} 로 시점에 실행할 수 있습니다.
      • invalidateQueries 를 사용해 명시적으로 query 가 stale 되는 시점을 정할 수 있습니다.
      • mutation 을 성공하고, 데이터를 fetching 해주는 것이 필요한 경우 해당 시점에서 관련 query를 invalidate 해줍니다.
      • onSuccess 는 mutation 이 성공하고 결과를 전달할 때 실행되며, useMutation option의 추가 콜백에서 첫번째로 실행되고, mutate 호출 후 추가 콜백에서 두 번째로 실행됩니다.
    useMutation(addTodo, {
       onSuccess: (data, variables, context) => {
         // I will fire first
       }
      });
    
    mutate(todo, {
       onSuccess: (data, variables, context) => {
         // I will fire second!
       }
    });
  • 원인 파악

    • 회원 가입만 진행하는 경우와 회원 가입/가게 정보 동시 등록을 나누어 가능하게 하려면 실행되는 조건을 나누고 그에 따라 호출되는 함수 작성이 각각 필요합니다.
    • react-query 가 작동하는 방식을 이해한 후 적당한 시점에서 mutation과 query 호출해야 기능이 구현됩니다.
  • 해결 방법

    • 조건 나누기

      • checkStore(클라이언트 가게 정보 등록 선택 여부)
        1. 클라이언트의 선택 여부를 확인합니다.
        2. form 작성 필수 여부를 선택합니다.
        3. user 쿼리를 호출할것인지 또는 바로 login 화면으로 넘어갈 것인지를 지정합니다.
      • saveStore(클라이언트의 선택에 따라 가게 등록 mutation 호출 여부)
        1. 등록 mutation을 호출하는 용도로만 사용합니다.
    • 회원 가입만 진행

      1. signup mutate - 첫 번째 fire 되는 onSuccess 에서 meQuery refetch 합니다.
      2. meQuery - user 정보를 recoil로 저장합니다.
      3.  useEffect - meQuery 에서 isSuccess 받아서 admin 페이지로 이동합니다.

      Update ⇒

      Loading 컴포넌트를 따로 작성해 meQuery 호출하고 user 정보를 저장하는 것으로 변경함에따라 이를 적용하여 수정했습니다. 1 에서 refetch 없이 add store mutate 실행하는 조건(checkStore)을 확인한 후 값이 false 이면 바로 Loading 페이지 이동

      2, 3 제거합니다.

    • 회원 가입, 가게 정보 등록 동시 진행

      1.  signup mutate - 첫 번째 fire 되는 onSuccess 에서 meQuery refetch + 두 번째 fire 되는 onSuccess에서 add store mutate 실행하는 조건(checkStore)을 확인한 후 saveStore state를 변경합니다.
      2.  meQuery - 위와 같습니다
      3. useEffect - add store mutate 실행하여 가게 정보를 저장합니다.
      4. add store mutate - onSuccess 에서 login 페이지로 이동합니다.

      Update ⇒

      1. **** signup mutate - 첫 번째 fire 되는 onSuccess 에서 checkStore 조건을 확인한 후 meQuery refetch + 두 번째 fire 되는 onSuccess에서 add store mutate 실행하는 조건(checkStore)을 확인 후 saveStore state를 변경합니다.
      2. meQuery - user 정보를 recoil로 저장합니다.
      3. useEffect - checkStore, saveStore 및 가게 정보 확인 후 add store mutate 실행합니다.
      4. add store mutate - 'store' key 가진 쿼리 invalidate 후 페이지 이동합니다.
  • 마무리

    컴포넌트 생명주기에 따라 mount, unmount 되는 시점을 파악하고, state가 저장되고 업데이트 되는 시점을 고려해 적절한 곳에서 refetching 을 해주어야 기능이 작동했습니다. 이렇게 manual하게 refetching 해주는 것을 추천하지 않는 글도 있었는데 react-query를 더 공부하고 사용해봐야 합니다.

Issue 2

자동 로그인

사용자가 새로고침을 하면 상태관리 도구에 저장된 데이터가 사라집니다. 따라서 로그인 정보가 유실되어 로그인을 다시 해야하는 경우가 발생하였습니다.

  • 원인 파악
    • 상태 관리 도구에 저장된 데이터는 새로고침을 하면 사라집니다.
    • App.tsx에 유저 정보를 다시 불러오는 로직이 없습니다.
  • 해결책
    • 로컬 스토리지에 유저 정보를 저장하고 Recoil에서 user 정보가 사라졌을 경우 로컬스토리지를 참조하여 사용자 정보를 가져와 로그인 정보를 유지시킵니다.
function App() {
  const [user, setUser] = useRecoilState(userState);

  const { getUser } = useGetUserInfoFromLocalStorage();

  useEffect(() => {
    const storageUser = getUser();

    if (storageUser === undefined) {
      return;
    }
    if (storageUser && !user.isLogin) {
      setUser(storageUser);
    }
  }, []);

  return (
    <BrowserRouter>
      <Router />
    </BrowserRouter>
  );
}
  • 마무리
    • 사실 이 방법에 대해서 해결책으로 제시했지만 민감한 정보들이 유출될 수 있기 때문에 좋은 방법은 아니라고 생각합니다. 왜냐하면 로컬 스토리지는 데이터를 삭제하기 전까지 거의 영구적으로 그 값을 저장하기 때문입니다.
    • JWT를 사용하는 경우 토큰을 쿠키에 저장하여 새로고침 시에 서버에게 유저 정보를 요청하는 방법이 있습니다. 개인적으로 JWT를 사용한다면 이 방법으로 해결하는게 더 좋지 않았나 하는 아쉬움이 있습니다.

Issue 3

📁 깃 플로우

브랜칭 전략은 처음에 매우 간단했습니다. 코드를 작성하는 사람의 이름과 작업하는 기능에 대해서 적도록 하였습니다.

$ git checkout -b hyunsu/admin-product

프로젝트 중반에 팀원의 제안으로 깃 플로우를 도입하기로 했습니다. 그러나 과연 팀 프로젝트에서 효과적이었는지는 의문입니다. 아마 제대로 사용하지 못했거나 팀원과 함께 규칙을 정할 때 깃 플로우에 대해서 정확히 무엇인지 이해를 못했을 가능성이 있었습니다.

  • 효과적이지 않았다고 생각하는 원인들
    • main, dev, feat, fix, chord, document, style로 브랜치를 나눠 PR을 했는데, feat로 대부분 작업을 하였고 feat와 fix가 대부분 혼용되어 사용되었습니다.
    • chord, style 브랜치는 조금 애매한 부분이 있는 것 같습니다. 초기 프로젝트 셋팅 과정에서는 필요한 브랜치일지는 모르지만 프로젝트가 진행되면서 설치되는 패키지가 있고 코드 스타일을 중간에 바꾸는 경우는 거의 없었습니다. 또한 fix와 chord가 혼용되어 사용되었습니다.
  • 효과적이었다고 생각하는 부분들
    • 다른 팀원의 브랜치와 충돌할 일이 없었습니다.
    • 해결책을 다같이 모색해야할 경우에 브랜치를 찾아 코드를 함께 보는 것이 매우 간편했습니다.
    • rebase를 사용하지 않았기 때문에 깃 분기가 매우 혼란스럽습니다. 하지만 브랜치가 어디에서부터 갈라져 나와 코드가 작성되고 merge 되었는지를 눈으로 보는 편이 더 좋을 것 같다고 판단하여 rebase를 사용하지 않았습니다.
  • 해결책
    • feat를 할 때 수정되는 컴포넌트나 설치된 패키지가 있다면 그냥 feat에 포함시켰습니다.
    • fix는 코드를 전반적으로 수정할 때만 사용했습니다.
    • dev는 사용하지 않았습니다. 사실 사용하지 못했다고 보는게 맞는 것 같습니다. 서버에 CI/CD를 하면서 dev 브랜치를 활용했어야 했는데 프론트 CI/CD는 프로젝트 후반에 할 수 있었기 때문입니다.(git action을 이해하고 적용하는데 애를 먹었기 때문에) 그리고 애초에 서버를 설정할 때 프론트는 dev용 포트가 없었습니다.

📁 변경된 폴더 구조에 따라 깃허브 원격 및 로컬 저장소 업데이트시 충돌 발생

  • 사실 수집- 필요 없는 중간 폴더를 제거하고 구조를 다시 변경하기로 결정 → 하위 폴더에서 작업하던 것들을 한 단계 상위 폴더로 전부 올림
    • 한 팀원이 폴더 구조를 작업하고 공용 메인 저장소(movie)에 업데이트 후 다른 팀원이 forked 받은 원격 저장소 및 로컬 저장소 동기화를 진행했습니다. 이 과정중에 각자 진행중인 작업 내용이 있어서 충돌이 발생했습니다.
  • 원인 파악
    • 폴더 구조 변경에 따라 .gitignore 파일 위치가 변동되고, 따라서 node_modules 가 git add에 포함되어 저장소에 변경해야 하는 업데이트 내용이 1000k 넘어가게 되었습니다.
    • 현재 작업하는 내용을 어디에도 기록하지 않고 동기화를 진행하다 보니 git checkout 이 되지 않고, 따라서 동기화가 불가능했습니다.
  • 해결 방법
    • git stash를 사용했습니다.
      • 폴더 구조 변경 전에 로컬 저장소 브랜치 feature/orderaction/issue#2 에서 작업 중인 내용을 git stash 처리한 후 원격 저장소로 publish 합니다.
      • 폴더 구조 변경이 완료된 원격 저장소 movie/main 를 가져옵니다. (git pull movie main 하는 과정에서 .gitignore 위치가 바뀌었기 때문에 git add 에 포함된 node_modules 를 삭제합니다)
      • 로컬 브랜치에서 commit & pull를 완료하면 이 시점에서 feature/orderaction/issue#2 는 새롭게 수정된 폴더 정보를 가지게 됩니다. git stash 했기 때문에 로컬에서 작업중이던 정보는 포함하고 있지 않습니다.
      • 동기화를 마무리하려면 pr을 보내 merge를 해야하는데 해당 브랜치는 아직 작업이 완료되지 않은 가장 ahead 된 브랜치이므로, feature/orderaction/issue#2 가 아닌 origin/main 에서 다시 진행하기로 합니다.
      • 로컬 브랜치 origin/main 로 이동해 movie/main 정보를 가져옵니다.
      • commit + push + pr + merge 로 각 팀원 main 폴더끼리 동기화를 완료합니다.
      • 다시 feature/orderaction/issue#2 으로 이동해 git stash pop 으로 작업 중이던 내용을 가져와 마무리를 합니다.
  • 마무리
    • 폴더 구조가 변경이 되었는데 git stash로 작업 중이던 내용은 어떻게 그대로 들어왔는지는 이해되지 않습니다. 폴더 구조는 기록 안하고 어느 폴더의 어느 파일이 변경되었는지만 기록하는 건지 확인이 필요합니다

Issue 4

상태관리 도구로 React Query와 Recoil을 사용한 이유

상태 관리 도구로 무엇을 사용 할 것인지 고민이 많았습니다.

📌 React Query를 사용한 이유

  • 많은 사람들이 사용하기 때문에 문제 발생시 해결을 하기 위한 자료가 많습니다.
  • query로 불러온 데이터를 캐싱합니다.
  • mutate 함수 호출 이후에 비동기 통신이 성공하면 query로 불러온 값을 invalidate 하여 캐싱된 데이터를 refetching 할 수 있습니다. 서버에 다시 데이터를 요청하는 코드를 작성할 필요가 없다는 이점이 발생합니다.
  • isSuccess, isLoading과 같은 API 제공해주기 때문에 서버 상태에 따른 UI를 구현하기 위해 따로 상태를 구현을 하지 않아도 되는 이점이 있습니다.
  • graphql 때문에 ApolloClient를 고려했으나 React Query가 graphql을 지원 합니다. 그리고 두번째 이점 때문에 쉽게 포기할 수 없었습니다.
  • 해결하지 못한 이슈
    • React Query가 컴포넌트 안에 포함되면서 컴포넌트가 필요 이상으로 거대해집니다.
    • 점점 React Query와 뒤섞여서 코드 유지보수가 매우 어려워 졌습니다.

📌 Recoil 를 사용한 이유

  • Recoil은 가볍습니다. 그리고 전역으로 상태 관리를 할 수 있다는 이점이 있습니다.
  • 사용 방법이 useState와 같아 사용이 매우 간편합니다.
  • React Query로 데이터를 불러올 시에 프론트 앤드에서 사용하기 편리하게 데이터를 다시 가공해야하는 이슈가 발생하였습니다. 이를 Recoil로 해결하였습니다.

Issue 5

Web Font가 적용 안된 문제

Web Font가 UI에 적용되지 않는 문제가 있었습니다.

  • 원인 파악
    • body에 font-family와 font-size를 적용하였는데 그렇게 하면 font의 기본 값이 UI에 반영되지 않았습니다.
  • 해결 방법
    • html에 font-family와 font-size를 작성하였습니다. 또한 input, button과 같은 form관련 태그에는 font-family가 적용이 안되기 때문에 개별적으로 font에 관련된 기본 값을 작성해주었습니다.

Issue 6

React Build 이후에 환경 변수값이 적용되지 않는 문제

build 이후에 local에서 사용하던 환경 변수가 프로젝트에 적용되지 않는 문제가 있었습니다.

  • 원인 파악
    • build 시점에서 env에 포함된 환경 변수가 주입되기 때문에 env에 포함된 secret을 build 이후에 인식하지 못합니다.
  • 해결 방법이라고 생각했던 것들
  • 마무리
    • secret이 필요한 기능은 반드시 서버를 통해서 해결 해야합니다.

Issue 7

setTimeout 함수 clearout 하기

📁 5초 카운트 후 페이지 자동 이동시 setTimeout 함수가 clearout 되지 않는 문제가 있었습니다.

  • 사실 수집
    • 주문 완료 후 영수증을 출력을 선택하는 화면에서 setTimeout과 setInterval 함수를 사용해 자동으로 화면을 이동하는 모달 구현 중 setTimeout 함수가 clearout 되지 않았습니다.
  • 원인 파악
    • useEffect 를 사용하지 않고 구현하는 경우, 사용자의 입력 값이 개입하면 setTimeout과 setInterval 함수가 clearout되지 않습니다.
    • setState 를 사용해서 상태를 업데이트 할 경우 업데이트 된 상태를 바로 반영되지 않는데, 이는 비동기적으로 작동하기 때문입니다.
  • 해결 방법
    • 사용자의 개입이 있는 경우와 아닌 경우(자동으로 페이지 이동)의 조건을 나누어서 각각 useEffect 를 실행합니다.
    • 모달 내에서 화면이 총 3번 이동하므로 의존성 값에 따라 useEffect 이 실행되고, 카운트는 업데이트 함수 형식으로 작성했습니다.
Clone this wiki locally