프로필사진


2022.02.01

2022년 1월

반응형

1. 들어가며

2021년 회고를 작성하다가 작년에 한 것이 너무 없다 보니, 한 달 단위로 일기를 해보면 좀 더 부지런한 삶을 살지 않겠냐는 생각으로 작성을 시작하게 되었습니다.

 

2021년 회고

1. 들어가며 2019년부터 여러 취업을 위한 활동을 시작했고 그 과정에서 백준이나, 프로그래머스로 코딩 테스트를 준비했습니다. 면접 및 자기소개서 준비는 중간에 공공기관에도 관심이 있었기

sotaneum.tistory.com

 

부안 격포

2. 이번 달에는 무엇을 했는가?

A. 여행

  이번 달에는 정말 오랜만에 바다 구경을 잠시 다녀왔습니다. 작년에는 코로나가 심해 어딜 나갈 생각을 못 했는데, 이번 설 기념으로 본가에 내려와 아버지와 함께 구경 갔습니다. 코로나로 사람이 많지 않았고 무엇보다 평일 오후라서 더 없었던 것 같습니다.

B. 개인 프로젝트

  개인 프로젝트로는 golang기반 몇 가지 프로젝트를 진행했습니다. 하나의 프로젝트를 여러 프로젝트로 분리하고 다양한 확장성을 확보할 계획을 세웠으나 golang에 대한 이해 부족으로 작업 실패를 맛보게 되었습니다. 지금은 유틸리티 형태를 제외한 부분은 하나의 프로젝트로 진행하고 있고 다음 달 정도에 서버는 정리될 것 같고 프론트 부분은 공부를 좀 더 해서 해볼 예정입니다.

C. 회사 생활

  연말정산과 평가가 있었고 회사 자체 이슈도 많았던 시기였던 것 같습니다. 작년부터 진행하던 프로젝트는 1월이 돼서야 작업이 시작되었지만, 담당하셨던 몇몇 분들이 나가면서 다소 애매한 상태에서 시작하게 된 것 같아 아쉬웠습니다. 최대한 이해한 내용을 바탕으로 작업을 우선 진행하고 비어있는 구간은 정리해서 논의했던 기억이 있습니다. 남은 시간에는 프로젝트 개선 작업과 평소 불편했던 여러 도구를 만들었던 것 같습니다.

D. 프로젝트 경험 - 이미지 스냅숏 테스트

  프로젝트에서 스냅숏 이미지 테스트를 사용하고 있습니다. 다만, 스토리북의 스토리 개수가 많다 보니, 한번 테스트할 때 시간이 많이 소요되었고 결국에는 Jest의 병렬 처리를 활용하게 되었습니다. 하나의 테스트 파일에 여러 개의 스냅숏 이미지 테스트를 적용하기보다는, 여러 개의 테스트 파일에 각 하나의 스냅숏 이미지 테스트를 적용하게 되면, 예상했던 병렬 처리를 할 수 있습니다. 다만, 한 번에 여러 곳에서 페이지 요청이 발생하기 때문에 테스트가 실패하는 경우가 있습니다. (실패로 재시도하는 시간을 합치면 안 하는 게 나을 수도 있습니다) 또한, 기존에 이미 이미지 테스트를 위한 검증 데이터가 있었는데, 여러 테스트 파일에서 이 검증 데이터를 그대로 사용하기 위해서는 옵션을 추가해야 합니다. 이 부분에 대해서는 따로 글을 작성해보려고 합니다.

E. 프로젝트 경험 - 함수 실행 시 종료될 때까지 재요청을 무시하기

  프로젝트에는 버튼을 클릭하면 API 요청이 여러 번 발생하게 되는데, 만약 반복적인 요청을 하게 될 때 매번 호출되는 이슈가 있었고 물론 제가 잘 못 구현...  로직이 깔끔하지 않았으며 꼬이는 현상도 있었습니다. 대표적으로 꼬이는 현상은 API 요청 후 페이지 이동이 발생하는 버튼을 여러 번 클릭했을 때, 페이지는 한번 이동하지만, 뒤로 가기를 했을 때, 같은 페이지를 버튼 클릭한 횟수만큼 보여주게 됩니다. 이러한 현상이 발생하게 된 이유는 React-Router의 history를 이용하여 페이지 이동을 하고 있었고 API 요청과 응답 사이에 여러 번 함수 실행이 발생하며 응답받을 때마다, history 스택에 쌓이기 때문입니다. 따라서 뒤로 가기 동작 시, 같은 페이지로 쌓인 스택이 많이 있으므로 그 양만큼 pop이 되어야 비로소 원하는 뒤로 가기를 시도할 수 있게 됩니다. 여러 번의 API 요청도 문제이지만, 필요하지 않은 여러 작업을 수행하기 때문에 반복적인 요청이 발생했을 때만 실행하도록 하는 방법이 필요했고 몇 가지 방법을 시도하게 되었습니다.

import React from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });
  const handleClick = async () => {
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
  };
  return <button onClick={handleClick} />;
}

①. react-query의 Query 상태 값 이용하기

react-query를 사용하고 있었고 Query 상태에 따라 함수 동작 여부를 결정하는 방식을 도입해볼 수 있었습니다. 함수 내에서 요청하게 되는 Queries의 각 상태가 요청 중일 경우에는 함수 실행 요청을 무시하도록 처리했습니다.

import React from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  // isFetcing으로 Query가 요청 중인지 확인합니다.
  const { isFetching, refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });
  const handleClick = async () => {
    if (isFetching) {
      return;
    }
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
  };
  return <button onClick={handleClick} />;
}

Query의 isFetching을 사용해서 true이면 함수 요청 시 종료하도록 수정했습니다. 하지만 이 방법은 위에서 발생한 이슈인 "바로가기 횟수"를 "버튼 클릭 수"에서 "5회 미만"으로 줄일 수 있지 실질적인 해결 방법은 아니었고 react-query options만 잘 설정했다면 이슈가 없었겠지만, 뒤로 가기로 이 페이지에 다시 돌아왔을 때, stale 여부에 따라 버튼이 동작하지 않는 이슈도 있었습니다.

②. throttle, debounce 사용하기

스크롤 이벤트에서 보통 사용하는 throttle, debounce 방법을 생각했으나, API 요청, 응답, 데이터 가공에 드는 시간을 예측해서 delay 값을 정해야 하는 부분이 사용자의 환경에 따라 이슈가 발생할 것으로 판단되어 테스트도 하지 않고 도입하지 않았습니다.

③. flag 사용하기 

함수가 종료된 이후에 실행하기 위해서는 mutex 묶는 방식처럼 flag를 사용하는 방법이 있습니다. 자바스크립트에는 클로저라는 개념이 있으므로 보통 이 방법을 사용해서 처리하는 것 같습니다. 함수가 시작될 때, flag 변숫값이 on일 경우 return 처리를 하고 off이면 on으로 변경한 다음 함수가 마무리될 때 off로 변경하는 방법입니다. 따라서 매번 함수가 실행은 되지만, 이미 실행되고 있는 함수가 있으면 return 처리가 되므로 API 요청을 하지 않고 종료하게 됩니다. 

import React from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });
  let flag = true;
  const handleClick = async () => {
    if (flag) {
      return;
    }
    flag = false;
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
    flag = true;
  };
  return <button onClick={handleClick} />;
}

하지만 이 방법은 바닐라에서는 가능하지만, React 특성상(혹은 가능할 수 있지만), 값이 변하면 re-render가 발생하기 때문에 flag 값은 항상 true를 가지게 되며 함수가 클릭될 때마다 매번 실행하게 됩니다.

④. flag + useState 사용하기

flag 값을 useState로 처리하여 re-render가 발생하더라도 유지하는 방법을 시도해봤습니다.

import React, { useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });
  
  const [flag, setFlag] = useState(true)
  const handleClick = async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
    setFlag(true)
  };
  return <button onClick={handleClick} />;
}

하지만 history.push 이후 짧은 시간이지만 함수가 한 번 더 실행될 가능성이 있었고 여러 번의 테스트 끝에 페이지 이동 직전에 함수가 실행되어 history stack에 더 쌓이는 이슈가 발견되었습니다.

⑤. flag + useState + setTimeout 사용하기

함수의 역할이 종료된 이후, setTimeout을 도입하여 충분히 대기한 다음 함수가 실행하는 방법을 생각해봤습니다. 따라서 history.push가 실행된 이후 일정 시간 대기한 다음 들어오는 함수 실행 요청을 처리하도록 했습니다.

 

import React, { useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });

  const [flag, setFlag] = useState(true);
  const handleClick = async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
    setTimeout(() => {
      setFlag(true);
    }, 1);
  };
  return <button onClick={handleClick} />;
}

하지만 이 방법은 setTimeout을 도입하기 전에도 발생했던 이슈이지만, 비동기 함수이기 때문에 메모리 누수(memory leak) 이슈가 100% 가깝게 발생할 가능성이 큽니다. history.push의 경우 페이지 이동이나 컴포넌트가 unmount 되었을 경우 함수의 마지막 라인인 `setFlag(false)`가 실행될 때, flag는 더 이상 state에서 관리하는 값이 아니기 때문에 메모리 누수가 발생합니다.

⑥. flag + useState + setTimeout + useRef + useEffect cleanup 사용하기

발생한 에러를 보면, useEffect cleanup 사용을 권고하고 있습니다. 함수 내에서 setFlag를 호출하다 보니, 이 상태에서 useEffect cleanup에서 처리하기 어렵다고 판단하였고 useRef와의 조합으로 해결하는 방법을 시도해봤습니다. ref에는 timeout id 값을 저장하고 컴포넌트가 unmount 될 때는 해당 id 값을 clearTimeout에 전달함으로써 timeout이 동작하지 않도록 막는 방법입니다.

import React, { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });

  const [flag, setFlag] = useState(true);
  const idRef = useRef();

  useEffect(() => {
    return () => {
      clearTimeout(idRef.current);
    };
  }, []);

  const handleClick = async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
    idRef.current = setTimeout(() => {
      setFlag(true);
    }, 1);
  };
  return <button onClick={handleClick} />;
}

하지만 이러한 방법은 페이지 이동이 발생했을 때는 history.push가 먼저 발생하고 그 이후에 unmount가 발생하고 그 이후에 setTimeout이 설정되면서 메모리 누수가 발생하게 됩니다.

⑦. flag + useState + setTimeout + useRef + useEffect cleanup + window.location.href 사용하기

점점... 사용하게 되는 리소스가 많아지지만, 페이지 이동에 대한 처리를 위해 window.location.href를 사용하는 방법을 떠올리게 되었습니다. 페이지 이동이 발생하면, 주소가 달라지기 때문에 해당 값이 변경되었을 때, return 처리를 해서 setTimeout에서 state 접근을 막을 수 있었습니다.

import React, { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });

  const [flag, setFlag] = useState(true);
  const idRef = useRef();

  useEffect(() => {
    return () => {
      clearTimeout(idRef.current);
    };
  }, []);

  const handleClick = async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const beforeUrl = window.location.href;
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
    idRef.current = setTimeout(() => {
      if (beforeUrl !== window.location.href) {
        return;
      }
      setFlag(true);
    }, 1);
  };
  return <button onClick={handleClick} />;
}

하지만 여러 함수에서 사용하게 될 예정이라, 매번 각 컴포넌트에서 작성하게 될 때 가독성이 떨어지므로 hook으로 분리해야 할 필요성을 느꼈습니다.

⑧. flag + useState + setTimeout + useRef + useEffect cleanup + window.location.href + custom hook 사용하기

점점.. "최종_정말최종_마지막.ppt"를 보는 것 같네요... 다양한 함수에서 동작할 수 있도록 실행하고자 하는 함수와 얼마나 지연 후 다시 실행할 수 있도록 할 것인지를 받는 hook을 추가했습니다.

import { useEffect, useRef, useState } from 'react';

export default function useFunc(func, delay = 1) {
  const [flag, setFlag] = useState(true);
  const idRef = useRef();

  useEffect(() => () => clearTimeout(idRef.current), []);

  return async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const beforeUrl = window.location.href;
    await func();
    idRef.current = setTimeout(() => {
      if (beforeUrl !== window.location.href) {
        return;
      }
      setFlag(true);
    }, delay);
  };
}

 

사용하게 될 때 아래처럼 사용합니다.

import React from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import useFunc from './useFunc';
import api from './api';

export default function App() {
  const history = useHistory();
  const { refetch } = useQuery('TEST_DATA', () => api.get('/api/v1/active'), { enabled: false });

  const handleClick1 = useFunc(async () => {
    const { data: active } = await refetch();
    if (active) {
      history.push('/list');
    }
  }, 500);

  const handleClick2 = useFunc(async () => {
    const { data: active } = await refetch();
    if (active) {
      history.push('/mypage');
    }
  }, 1);

  return (
    <>
      <button onClick={handleClick1} />
      <button onClick={handleClick2} />
    </>
  );
}

 

하지만, 실행하고자 하는 함수에 에러가 발생했을 경우, 버튼이 계속 막힌 상태로 있는 이슈가 있었습니다. 

⑨. flag + useState + setTimeout + useRef + useEffect cleanup + window.location.href + custom hook + try-catch 사용하기

실행하려는 함수가 있더라도 함수 실행이 가능하도록 try-catch를 추가하는 방법을 생각해 봤습니다.

import { useEffect, useRef, useState } from 'react';

export default function useFunc(func, delay = 1) {
  const [flag, setFlag] = useState(true);
  const idRef = useRef();

  useEffect(() => () => clearTimeout(idRef.current), []);

  return async () => {
    if (flag) {
      return;
    }
    setFlag(false);
    const beforeUrl = window.location.href;
    try {
      await func();
    } catch (e) {
      throw e;
    } finally {
      idRef.current = setTimeout(() => {
        if (beforeUrl !== window.location.href) {
          return;
        }
        setFlag(true);
      }, delay);
    }
  };
}

catch에서 throw 처리를 함으로써 이 함수를 호출하는 곳에서 에러 처리를 할 수 있도록 했고, finally 처리를 통해서 버튼을 다시 클릭할 수 있도록 했습니다.

⑩. 최종

flag + useState + setTimeout + useRef + useEffect cleanup + window.location.href + custom hook + try-catch 방식을 사용하게 되었고 "+"가 많은 것처럼 비용이 정말 많이 드는 hook이 아닐까 생각됩니다. 따라서 모든 함수에 적용하기보다는 요청이 한 번만 발생해야 하는 특수한 상황에서만 사용하는 게 좋을 것 같습니다. 또한, 놓친 부분이 많을 것 같아서 모든 상황에서 동작하지 않을 거로 생각합니다. 아마 이런 형태의 패턴이 있을 거라 생각이 들고 책을 많이 읽지 않아서 이름은 모르겠습니다. 아시는 분은 댓글 부탁드립니다. (_ _) 노운 이슈로는 hook을 선언한 컴포넌트에서 함수를 실행해야 하고 인자로 전달된 함수는 매번 실행하게 됩니다. 이 부분은 아직 테스트를 많이 해본 상태가 아니라 원인은 파악하지 못했습니다. (다음 달에 도전을...)

3. 마무리

  이번 달에는 useRef + useEffect 조합으로 cleanup을 구성하는 방법과 스냅숏 이미지 테스트를 병렬 처리하는 방법을 배웠고 비록 테스트했던 내용을 생각에 흐름에 따라 작성했지만 일기를 작성하는 목표를 달성했다는 점이 좋았습니다. 앞으로도 계속해서 작성할 수 있으면 좋겠습니다.

반응형

'일기장' 카테고리의 다른 글

2022년 3월  (0) 2022.04.02
2022년 2월  (0) 2022.03.02
2021년 회고  (0) 2022.01.23
다른 '일기장'의 최근 글