ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • useEffect가 나를 열받게 했다
    Web/React 2022. 7. 15. 20:32
    반응형

    이번에 새로운 기능을 개발하면서 상태의 변화에 따른 side effect들을 처리해주기 위해 useEffect를 많이 사용하게 됐다.

    기능의 생명 주기를 stage라는 단위로 구분했는데, 원래는 페이지 단위로 사용자의 행동을 구분하는 것이 보편적이겠지만

    사실 페이지의 형태도 거의 비슷하고 로직만 약간씩 바뀌는 식이어서 굳이 페이지를 나눌 필요가 없겠다는 생각이었다.

    대충 요런 느낌

    그래서 사용자가 '이번 페이지에서 할 작업을 다 마쳤으니 다음 페이지로 넘어간다' 라는 의도로 '다음' 버튼을 누르면

    동일한 페이지 내에서 stage만 바뀌고, stage의 변화에 따라 useEffect에서 여러 작업을 수행하는 방식으로 구현을 했다.

    useEffect(() => {
      if (stage === ‘first-stage’) {    
        // 여러 가지 side effect들
        // ex) 로그, 이미지 처리, 로더 등등
        return;
      }
    
      if (stage === ‘second-stage’) {
        // ...
        return;
      }
    }, [stage]);

    큰 고민 없이, 'side effect는 useEffect 훅에서 처리한다' 라는 기존의 관념대로 코드를 짰다.

    당연히 기능 구현 초반에는 로직도 복잡하지 않고 코드도 길지 않다보니,

    '뭐, stage라는 구분 단위도 명확하고, stage 관련 로직들도 페이지 안에 잘 모여있는 것 같다.

    읽는 것도 어렵지 않고.. 의도도 뚜렷하고.. 이대로 가도 될 듯? ㅋ' 하고 생각했지.

     

    하지만 프로젝트를 진행하면서 기능이 복잡해지고, 각 stage의 로직이 비대해지면서 점점 처음의 의도와 다르게 흘러갔다.

    useEffect(() => {
      if (stage === ‘first-stage’) {
        if (a === ‘something’) {
          // 대충 side effect 처리
        }
        // 대충 다른 조건 + 다른 side effect
      }
    
      if (stage === ‘second-stage’) {
         if (b === ‘something2’ && c) {
           // 대충 또 다른 side effect 처리
         }
        // 대충 또 다른 조건 + 또 다른 side effect
      }
      
      // 대충 다른 stage 로직
      // 대충 이것저것
      // 대충 뭐가 많음
    }, [stage, a, b, c, d, e, ...]);

    상황이 이렇게 되자, 몇몇 문제점들이 피부로 느껴지기 시작했다.

    1. useEffect들이 여기저기에 산재하게 되고, 각각의 크기 또한 점점 불어났다.
    2. useEffect의 트리거(dependency들을 변화시키는)들 또한 곳곳에 산재되어 있었다.
    3. 처음에 의도했던 'stage 변화에 따른 side effect의 수행'과는 점점 거리가 멀어지고 있다고 느낄 정도로
      side effect의 수행에 영향을 미치는 상태값들이 점점 늘어났고, 그만큼 deps 배열(dependencies)도 점점 불어났다.
    4. side effect를 담고 있는 useEffect가 자꾸 여러 번 실행되는 것도 꽤 거슬렸다.

    등등.

     

    이 즈음 와서는, 뭔가 에러가 발생했을 때 대체 어디서 이 side effect가 트리거 되는지를 파악하는 것이나,

    이 에러가 도대체 어떤 dependency 때문에 발생한 에러인지를 파악하는 게 거의 어떤 '정신적인 노가다'의 경지였다.

    물론 그걸 파악한 후에 에러를 해결하는 건 또 다른 문제였고.

    이렇듯 디버깅의 과정이 점점 고통스러워지면서, 함께 페어로 개발하던 동료 개발자 아그라작님과 나는 굳게 다짐했다.

    언젠가는 저 useEffect놈을 산산조각내리라. 형체도 없이 분해해버리고 말겠다. 😡 (이정도는 아니었고..)

    하지만 우리의 우선 순위는 코드 퀄리티보다는 마감 기한 준수였기 때문에 고민은 제껴두고 일단 기능 개발에 집중했다.

    당연하게도, 개발되는 기능의 크기에 비례하여 useEffect의 체중 또한 점점 늘어만 갔다.

     

    이후에 기능을 릴리즈하고 약간의 텀이 생기면서, 다음 기능 개발에 들어가기 전에 호다닥 리팩토링에 돌입하기로 결정했다.

     

    기억들이 잊혀지기 전에, 아그라작님과 나는 개발하면서 고통스러웠던 점들을 하나하나 나열해보았다.

    그 중 가장 큰 화두가 되었던 부분은 역시나 useEffect에 관한 내용.

     

    사실 이미 말했듯이 우리 둘 다 useEffect를 개선해야겠다는 생각은 진작에 하고 있었지만, 

    워낙에 기능 개발로 바빴던 탓에 제대로 이야기해 본 적이 없었기에 어떤 식으로 개선해야할 지가 제법 막막했다.

    그 때 아그라작님께서 '이렇게 해보는 건 어때요' 하고 요런 영상을 가져오셨다.


    필요한 내용만 요약하자면 대충 다음과 같다.

    side effect는 synchronized effects와 action effects 두 가지로 나눌 수 있다.

     

    ◼︎ synchronized effects

    • React와 외부 시스템과의 동기화를 위해 사용
    • 예를 들면 컴포넌트가 mount 될 때 DOM 이벤트 리스너를 subscribe하고, unmount 될 때 unsubscribe 해주는 식.
    • 얘는 useEffect에서 처리해줘도 된다.
      (요 용도로만 사용해야한다고 주장함. 그래서 이름도 useSynchronization으로 바꾸란다)

     

    ◼︎ action effects

    • 한 번 fire 시키고 잊어버리는 단발성 동작들
    • useEffect에처 처리하면 문제가 많기 때문에 다시 한 번 생각해보는 게 좋단다.
      • 대표적으로, React 18이 strict mode에서 mount 시점에 useEffect를 두 번 실행시키는 문제가 있다. 그 외에도 side effect를 컴포넌트의 rendering cycle 안에 두면(쉽게 말해 useEffect 안에 두면) side effect가 여러 번 실행되는 일이 종종 발생하곤 한다.
      • 내가 겪었던 코드가 복잡해지는 문제는 덤. (이건 사실 useEffect 자체의 문제에 가까운 듯)

    • 그러면 action effects는 어디서 처리해줘야 하나? 바로 이벤트 핸들러. (onSubmit, onClick 같은 애들)
      • side effect를 이벤트 핸들러에서 처리해주면 side effect가 여러 번 실행되는 일이 없다.
      • 이벤트 핸들러는 컴포넌트가 몇 번 렌더링 되는지와 전혀 관계가 없고, 무조건 한 번만 실행된다는 게 보장되기 때문.
      • 이벤트 핸들러는 컴포넌트의 rendering cycle과 따로 놀아서 side effect들이 렌더링의 영향 밖으로 벗어날 수 있다.

     

    이벤트 핸들러에서 처리해주라는 건 알겠는데, 어떻게 처리해줘야 하나?

    state + event → nextState 이므로, 이 event로 인해 발생하는 state transition과 함께 처리해주면 된다.

    즉, state + event → nextState + effects. 예를 들면 idle state + LOAD → loading state + fetchData 같이.

     

    아래의 훅이 코드로 나타낸 예시.

    function useSpicyReducer(reducer, initialState, executeEffect) {
      const [state, setState] = useState(initialState);
      
      const spicyDispatch = useCallback(
        (event) => {
          const nextState = reducer(state, event);
          
          executeEffect(state, event, nextState);
          
          setState(nextState);
        },
        [reducer, state, executeEffect]
      );
      
      return [state, spicyDispatch];
    }

    참고로, 이벤트 핸들러에서 side effect를 처리한다는 개념은 저 수염난 아저씨만의 주장은 아니다.

    리액트 개발자로 유명한 댄 형(Dan Abramov)을 필두로 리액트 공식 문서를 다시 쓰는 프로젝트가 있는데,

    거기서도 '물론이죠! 이벤트 리스너는 side effect를 처리하기에 최적의 장소입니다!' 하고 언급돼있다.

    추가로, useEffect는 side effect를 다루는 마지막 수단이어야 한다고도 언급되어 있다. 😲

    단순히 이름만을 근거로 곧이곧대로 useEffect에서 side effect를 처리해주던 나에게는 꽤나 충격으로 다가왔달까.


    그 밖에 이런저런 내용들이 많지만, 아그라작님이 꽂히신 부분은 이벤트 핸들러와 예시에 나오는 reducer 부분이었다.

    reducer로 side effect 로직들을 한 데 모아두고, 이벤트 핸들러에 dispatch를 달아서 처리하면 useEffect를 거치지 않아

    원치 않는 동작을 줄이고 디버깅을 수월하게 할 수 있으리라 생각하셨던 것 같다.

    useEffect가 줄어드니까, 로직이 여러 번 실행되는 문제에서도 벗어날 수 있었고. 👏

     

    결과적으로 보자면, reducer를 직접 코드에 도입한다거나 하진 않았지만

    요 영상은 우리가 망설임 없이 recoil을 도입할 수 있게 이끌어주는 역할을 했다.

    뜬금없이 왠 recoil이냐 싶다면.

    recoil을 도입하면 비슷한 효과를 누림과 동시에 다른 장점들까지 챙길 수 있었기 때문이다.

     

    1. reducer를 도입하려는 가장 큰 이유는 side effect 로직들을 따로 빼서 useEffect의 크기를 줄이는 것 때문이었는데

    recoil을 도입해도 atom + selector를 활용하여 유사한 효과를 챙길 수 있었다.

     

    더불어, selector로 빼기 애매한 로직들은 useRecoilCallback을 활용해서 분리해줬는데,

    useRecoilCallback을 활용하면 리렌더링을 시키지 않고도 특정 atom을 읽거나 조작할 수 있었다.

    요녀석들을 이벤트 핸들러에 넣음으로써 코드 개선뿐만 아니라 성능 측면에서도 이점을 얻을 수 있었다.

     

    흩어져있던 useEffect들 중 밖으로 빼지 못하고 남은 애들은 <StageEffect />라는 컴포넌트를 만들어 모아두었다.

     

    2. 리팩토링을 하면서 과하게 커진 컴포넌트들은 더 작은 컴포넌트로 분리를 하게 됐는데,

    stage와 같은 상태값들은 root에서 전달해줘야하는 단일 상태값이었기 때문에 props drilling이 점점 심해졌다. 

    recoil을 사용하면 이러한 상태들을 전역으로 빼서 props drilling을 없앨 수 있다는 장점이 있었다.

     

    3. 보통 로그를 찍는 작업은 기능적인 부분들을 먼저 구현하고 맨 나중에 하게 되는데,

    그러다 보니 기존에 예상치 못했던 상태값들이 로그만을 위해 컴포넌트에 props로 들어가게 되었다.

    이런 props들도 recoil에 '~AtomForLog, ~SelectorForLog' 라는 식으로 전역 상태를 정의함으로써 없애줄 수 있었다.

     

    요런저런 점들을 생각하다보니 그냥 아예 recoil을 도입해버리는 게 합리적이라는 판단이 섰다. 

    실제로 도입해보니 꽤 효과적이기도 했고, 생각했던 것보다 더 사용감이 좋아서 굉장히 만족스러웠다.

     

    다만 여전히 <StageEffect /> 컴포넌트 내부에 stage를 deps로 가지는 useEffect들이 남아있다는 게 찝찝한데,

    이는 stage별로 이미지를 blobURL로 다루거나, base64 혹은 remote URI로 다루는 등 

    이미지를 각각 서로 다르게 처리해줘야 하는 부분이 있기 때문이다.

    근데 이걸 또 반드시 SVG 컴포넌트가 그려지고 난 이후에 처리해야 한다.

    이런 점들을 모두 고려해봤을 때 얘는 그냥 useEffect로 처리하는 것이 맞겠다고 판단했다.

    (얘네도 어찌 됐건 StageEffect 안에 옹기종기 잘 모아놨으니 이전처럼 사방을 헤맬 필요는 없어졌다는 것에 만족)

     

    그 밖에 찝찝한 부분들이 이곳저곳 남아있기도 하고, 새롭게 보이는 문제도 있고.. 하지만.

    맘에 안드는 부분은 항상 있기 마련이다.

     

    그래도 원래 파일 당 6~700줄이 되던 코드들을 최대 약 2~400줄 정도로 줄일 수 있었다.

    파일도 제법 합리적인 단위로 잘 나누어졌고, 코드를 읽는 피곤함도 이전보다 훨씬 덜해졌다. 👍

    stage 관련해서 문제가 생겨도 이제 어떤 파일을 찾아가야 하는지가 명확해져서 디버깅도 수월해졌다.

    다른 건 몰라도 stage와 useEffect에 한해서는 제법 만족스러운 리팩토링이었다는 생각.


    여담이지만, 이번에 리팩토링을 진행하면서 아그라작님과 전체적인 프로젝트의 구조를 도식화 시켜놓고

    그걸 참고하면서 리팩토링을 진행했는데, 이게 너무 많은 도움이 됐다. 도식화의 효과를 절감했다고나 할까.

    근데 사실 아그라작님과 같이 도식화할 때는 항상 먼저 스타트를 끊어주셨던지라,

    이후에 따로 과제를 수행하거나 리팩토링할 때 밑바닥부터 혼자 도식화를 해보려고 하니까 생각보다 너무 어려웠다.

     

    모든 상태값을 그리면 너무 더러워져서 어떤 주제로 어떤 상태값들에 초점을 맞춰서 도식화 할지를 잘 결정해야하는데,

    그 부분을 아그라작님이 너무 자연스럽게 잘 해주셨던 것. 근데 나는 한 번도 안해봤으니 뭐가 잘 될 리가 있나.

    계속 연습하다보면 되겠지..


    개인적으로 이번에 페어로 함께 진행한 리팩토링이 꽤나 만족스러웠기에 

    useEffect에서 문제를 느낀 것부터 시작해서, 어떻게 리팩토링할지 함께 논의하고 조사하여 아이디어를 채택하고, 

    고민 끝에 더 나은 방법인 recoil을 선택하여 도입한 이유까지 자연스러운 흐름으로 어렵지 않은 글을 쓰고 싶었다.

     

    근데 이거 뭐 막상 써보니 핵심 주제도 없고 뭔 말인지 알아듣기 어렵기만 하고 아주 요상한 글이 탄생했다.

    뭐 어차피 블로그에 사람도 잘 안 들어오는데.

    아몰랑 

    반응형

    댓글

Designed by Tistory.