[트러블슈팅][React] useEffect는 렌더링 '이후'에 실행된다

Gyuwon Lee·2022년 10월 19일
14

🛠 문제상황

사용자들은 '내 사물함' 페이지에서 간단한 메모를 저장해둘 수 있다. 기존에는 localStorage에 저장되는 방식이었는데 브라우저를 바꾼다든가, 컴퓨터를 껐다 켰다든가 하면 날아가는 부분 등이 문제가 되어 DB에 저장하는 방식으로 변경했다.

그런데 얼마 전 수정취소 버튼을 만들면서 필요없어 보이는 일부 로직을 수정한 것이 원인이 되었는지, 수정확인 버튼을 눌렀을 때 API 요청 및 DB 저장은 정상적으로 되는데도 새로고침하면 기본값("필요한 내용을 메모해주세요")으로 돌아가 있는 문제가 발생했다. (그저께 분명 잘 돌아가는 걸 확인했는데?)

사진 속 페이지의 위치 정보나 대여일 등 모든 정보를 동일한 객체 myLentInfo 에서 받아오고 있어서, 다른 데이터는 잘 표시되는데 메모 부분만 문제가 생기는 것으로 보아 API 응답에는 문제가 없고 메모 부분 업데이트 로직의 문제일 것이라고 생각했다.

🪨 걸림돌

문제의 원인을 정확히 짚어내는 데 헤맸던 원인은 다음과 같이 진단했다:

1. 헷갈리는 컴포넌트 역할

📌 TL;DR
'메모장 내부 텍스트' 라는 하나의 view 요소에 관여하는 변수가 currentContent, textValue, inputValue로 3가지나 되고, 각자 선언 혹은 업데이트되는 시점이 모두 다른 로직에 있다는 점이 코드 가독성을 떨어뜨리는 큰 요인이 되었던 것 같다.

위 사진 속 비밀스러운 메모장 요소의 구조는 아래와 같다.

<LentTemplate>
  <LentInfo />
    <LentTextField />
      <EditButton />
    </LentTextField> 
  </LentInfo>
  <ReturnButton />
</LentTemplate>
  • LentTemplate : atomic design pattern의 template 단위 요소로, 구성 요소들을 적절하게 배치하는 역할
  • LentInfo : organism 단위 요소로, 사용자의 대여 정보 데이터를 받아 하위 요소들에게 뿌려주는 역할
  • LentTextField : Molecule 단위 요소로, 사물함 제목 및 사용자 메모장에 사용된다.
  • EditButton : atom 단위 요소로, 수정 창을 열고 수정내용을 서버로 보내거나 수정을 취소하는 기능을 지닌다.
  • ReturnButton : Molecule 단위 요소로, 사물함 반납 기능을 지닌다.

여기서 ReturnButton과 LentInfo가 모두 cabinet_type 데이터를 필요로 하면서 같은 계층에 있으므로, API 호출은 공통 부모인 LentTemplate에서 하게 되었다.

문제는 LentTextField와 EditButton의 역할 분리였다.

LentTextField는 수정하지 않고 있을 때 보여지는 값인 textValue와 수정창에서 보여지는 inputValue를 state로 갖고 있다. 여기서 textValue는 최초 렌더링 시 useEffect에 의해 props로 받아온 currentContent 데이터에 따라 아래와 같이 업데이트된다.

useEffect(() => {
    console.log(currentContent);
    if (currentContent) {
      setTextValue(currentContent);
    } else {
      setTextValue(
        contentType === "title"
          ? "방 제목을 입력해주세요"
          : "필요한 내용을 메모해주세요"
      );
    }
  }, []);

즉 데이터 흐름이 다음과 같아진다:

  • LentTemplate의 API 호출 및 myLentInfo state에 저장
    • LentInfo로 myLentInfo 전달
  • LentInfo는 LentTextField에 myLentInfo.cabinet_memocurrentContent props로 전달
  • LentTextField는 currentContent에 따라 최초 textValue state 업데이트
    • textValue에 의존하여 inputValue state 업데이트
    • EditButton에 textValue 및 inputValue 전달 (각 state의 변경함수 포함)
  • EditButton은 props inputValue 에 따라 textValue 수정 후 서버로 전달
    • 서버 전달 결과에 따라 에러 발생 시 textValue 되돌리기

여기서 textValue 업데이트 로직이 LentTextField와 EditButton에 모두 존재하는 부분이 혼동을 가중시켰다. 또한 inputValue는 전적으로 textValue의 변경에 의존하여 변경되지만, 수정을 취소하는 경우에는 예외적으로 setInputValue에 의해 변경되어야 한다는 점 역시 코드를 다시 파악하는 과정에서 헷갈리게 만들었다.

애초에 스스로 짠 코드를 다시 파악하는 데 시간이 걸렸다는 점부터 반성해야 할 것 같지만 🥲

로직이 여러 군데 있을수록, 또 같은 view 요소(여기서는 메모장 내부의 텍스트)에 관여하는 변수가 많을수록 데이터 흐름과 디버깅 과정이 어려워진다는 점을 깊이 체감했다.

2. 비동기 동작 및 컴포넌트 마운트 과정에 대한 이해도 부족

📌 TL;DR
useEffect가 '컴포넌트 마운트 직전에 실행되는 것'으로 잘못 알고 있었던 점으로 인해 원인이 비동기 동작에 있는 것으로 오인하고 불필요하게 시간을 지체시켰다.


페이지가 처음 렌더링될 때 여기저기 console.log를 찍어서 확인해 보곤(어떤 값인지는 여기서 별로 중요하진 않다), 처음에 undefined와 빈 배열이 뜨는 것에 멘붕했다. 아예 데이터가 안 들어가고 있는 거라고?

애초에 페이지 구조가 위에서 이야기한 바와 같은데 LentTemplate부터 순서대로 console.log가 찍히지 않고 LentTextField부터 console.log가 찍히는 것도 당황스러웠다.

그래서 문제의 원인이 비동기 동작에 있는 줄 착각하고 잘못된 부분을 파고드느라 시간을 썼다. 왜냐하면 console.log를 전부 useEffect 내부에서 찍고 있었기 때문인데, setState는 동기성을 보장하지 않는다고 얕게 알고 있었던 부분과 맞물려 "setState가 실행되는 동안 아래의 return문이 먼저 실행되는구나!" 라고 문제의 핵심을 잘못 파악했다.

내가 기대한 흐름은 다음과 같았다:

  • LentTemplate에서 먼저 useEffect가 실행된다.
    • 내부에서 axios 호출로 response를 받는다.
    • 호출에 대한 프라미스 체이닝으로, setMyLentInfo를 통해 state에 해당 response를 담는다.
    • useEffect를 마치고 아래 return문이 실행되며 LentInfo 컴포넌트가 읽힌다.
  • LentInfo 컴포넌트의 return문이 실행되며 LentTextField에 myLentInfo.cabinet_memo를 currentContent props로 넘겨준다.
  • LentTextField에서 useEffect가 실행된다.
    • 받아온 currentContent의 값에 따라 textValue를 업데이트한다.
    • 업데이트된 textValue의 값에 따라 inputValue를 업데이트한다.
    • return문이 실행되며 EditButton에 props를 넘긴다.
  • Editbutton이 렌더링된다.

상위 컴포넌트의 useEffect 실행 ➔ 그려짐 ➔ 자식 컴포넌트의 useEffect 실행 ➔ 그려짐 ➔ ... 과 같이 무조건 top-down 방식으로 렌더링될 것이라고 착각한 것이다.

여기서 가장 큰 착각은 useEffect가 컴포넌트 마운트 직전에 실행된다고 생각한 점이다. useEffect는 컴포넌트가 렌더링된 직후에 실행되는 hook이다.

정확히는 useEffect에 등록해둔 effect(부수효과함수)가 별도 메모리에 올라가 있다가 렌더링 이후에 실행되는 것이지만, 편의상 아래에선 그냥 useEffect가 실행된다고 하겠다.

컴포넌트 마운트 직전에 실행하고 싶은 코드가 있다면 useLayoutEffect를 사용해야 한다. 그래야 브라우저가 화면에 Paint 되기 전에, 해당 hook에 등록해둔 effect가 '동기'로 실행된다. 이 때 state, redux store 등의 변경이 있다면 한번 더 재렌더링 된다.

따라서 useEffect를 사용한 내 코드의 실제 동작은 다음과 같아진다:

  • LentTemplate이 렌더링되려 한다.
    • 이를 위해서는 자식인 LentInfo가 렌더링되어야 한다.
  • LentInfo가 렌더링되려 한다.
    • 자식인 LentTextField가 렌더링되어야 한다.
  • LentTextField가 렌더링되려 한다.
    • 자식인 EditButton이 렌더링되어야 한다.
  • 최하위 컴포넌트인 EditButton이 렌더링된다.
  • 비로소 LentTextField가 렌더링된다.
    • 렌더링 직후 useEffect가 실행된다.
  • LentInfo가 렌더링된다 ➔ useEffect가 실행된다.
  • 최상위 컴포넌트인 LentTemplate이 가장 마지막에 렌더링된다.
    • 모든 자식 컴포넌트 및 자기 자신이 전부 렌더링된 후, useEffect가 실행된다.

내가 예상한 방향과 정반대로 컴포넌트들이 마운트되고 있었던 것이다.

따라서 위에 첨부된 사진과 같이 LentTextField의 console.log가 먼저 찍힌 건, 비동기 동작이 아니라 내가 컴포넌트 렌더링 과정을 착각한 데서 발생한 문제였다. 모든 console.log를 useEffect 내부에서 찍고 있었기 때문인데, console.log를 useEffect 바깥으로 빼면 아래와 같이 top-down 방향으로 잘 찍힌다.

🔎 원인

앞서 보았듯 문제의 근원은 useEffect를 잘못 사용하고 있는 부분임을 알아냈다. 기존 코드의 textValue 업데이트 로직은 아래와 같았다.

// LentTextField.tsx

useEffect(() => {
    if (currentContent) {
      setTextValue(currentContent);
    } else {
      setTextValue(
        contentType === "title"
          ? "방 제목을 입력해주세요"
          : "필요한 내용을 메모해주세요"
      );
    }
  }, []);
  • currentContent : 조상인 LentTemplate 이 렌더링될 때 호출되는 API로부터 받아오는 데이터 (LentTemplate ➔ LentInfo ➔ LentTextField)

위 코드에 내가 기대한 동작은 이렇다:

  • LentTemplate이 렌더링되며 currentContent를 받아옴
  • props 통해 LentTextField에 데이터 전달
  • LentTextField의 useEffect 실행
    • LentTextField 렌더링
    • 의존성 배열이 비어 있으므로 최초 렌더링 이후에는 실행되지 않음

이 상태에서 수정 후 저장 버튼을 누르면 다음과 같은 동작이 발생할 것이다:

  • API 호출, DB에 저장
  • textValue 업데이트
    • textValue에 의존하는 inputValue도 업데이트

하지만 LentTextField의 실제 동작은 아래와 같았다:

  • LentTemplate이 렌더링되는
  • props 통해 LentTextField에 데이터 전달: useEffect 실행 전이므로 초기값 null이 전달됨
  • LentTextField 렌더링
  • LentTextField의 useEffect 실행: currentContent가 null로 전달된 상태이므로 textValue는 기본값("방 제목을~" 또는 "필요한~")으로 업데이트

따라서 저장버튼을 누른 직후에는 값이 잘 들어간 것처럼 보인다. 하지만 새로고침을 누르면 위의 잘못된 동작으로 인해 무조건 textValue는 기본값으로 설정되는 문제가 생긴 것이다.

💡 해결

이 문제는 같은 42cabi 프론트 팀의 hybae님이 간단히 해결해 주셨다. 위 useEffect의 dependency array에 currentContent를 넣으면 된다.

// LentTextField.tsx

useEffect(() => {
    if (currentContent) {
      setTextValue(currentContent);
    } else {
      setTextValue(
        contentType === "title"
          ? "방 제목을 입력해주세요"
          : "필요한 내용을 메모해주세요"
      );
    }
  }, [currentContent]);

이러면 수정 후 새로고침 시, 위의 문제 동작은 같지만 추가적으로 아래와 같은 재렌더링 과정이 일어난다:

  • LentTextField의 useEffect까지 모두 실행된 상태
  • 비로소 LentTemplate이 렌더링됨
    • 렌더링을 마치고 LentTemplate의 useEffect가 실행됨
    • 여기서 axios 호출이 일어남 ➔ DB에 업데이트된 새로운 값을 받아옴
    • setMyLentInfo에 의해 state 변경
    • state 변경으로 인해 자기자신 및 자식 컴포넌트 재렌더링
  • 이번엔 제대로 myLentInfo 데이터가 들어간 props가 전달됨
  • LentTextField의 useEffect 실행: 최초 마운트 시점에만 실행되었던 기존 코드와 달리, 이젠 currentContent가 변경될 때마다 실행됨
    • currentContent가 존재하므로 해당 값으로 textValue 업데이트

따라서 결과적으로는 새로고침해도 업데이트된 값이 잘 보이게 된다!

같은 데이터를 사용하는 LentInfo 의 나머지 부분들은 아예 useEffect를 사용하지 않고 있어서 문제가 확인되지 않은 것이었다.

🤔 생각해볼 점

위와 같은 방식으로 문제는 해결되었지만, 순식간에 렌더링이 두 번 일어나는 방식이기 때문에 새로고침 시 기본값이 잠깐 깜빡거렸다가 값이 들어오는 문제가 발생한다. 또한 애초에 기대했던 로직이 아니라 문제 상황은 여전히 발생하고, 재렌더링으로 그 위에 덮어씌우는 방식이기도 하다.

useLayoutEffect든 useEffect든 return 실행 및 렌더링 단계 이후에 effect가 실행되므로 재렌더링을 아예 막을수는 없다.

하지만 useLayoutEffect의 경우, effect의 실행 과정에서 state가 변경되면 재렌더링 된 후에 비로소 컴포넌트를 화면에 paint하므로 적어도 사용자에게는 깜빡거리지 않고 한번에 최신 컴포넌트가 보여지게 된다.

렌더링 ➔ paint ➔ 재렌더링 ➔ paint렌더링 ➔ 재렌더링 ➔ paint 차이인 것 같다. 따라서 useLayoutEffect를 사용해서 다시 개선해볼 수 있는 코드인 것 같기도 하다.


참고:

profile
하루가 모여 역사가 된다

0개의 댓글