기록의 습관화
article thumbnail

이번 포스트에서는 React Component의 reRendering에 대해서 다뤄보려고 합니다.

리렌더링은 안 좋은 거다. useCallback이나 useMemo를 사용해서 최적화를 해야 한다 등 여러 가지

말들이 정말 많은 주제이죠.

하지만 내 컴포넌트가 정말 합당하게 reRendering이 되고 있는지는 고민해봐야 할 문제입니다.

(요번에 호되게 당했기 때문…)

그럼 한 번 알아볼까요?


왜 reRendering이 되는 걸까?

예시는 주차신청 에플리케이션이며

신청 로직의 경우 step 별로 이루어지기 때문에

Toss의 컨퍼런스 내용의 Funnel 패턴을 이용하여 구현하였습니다.

(Funnel 패턴이 무엇인지 궁금하신 분들은 아래 링크를 참고해 주세요)

또한 유저에 대한 상태관리는 전역적으로 사용됨을 고려하여 Context API를 이용하여

Reducer를 이용하여 구현하였습니다.

 

신청 화면

  • 화면상에서의 조건은 다음과 같습니다.

화면 상에서 유저

  1. 신청 시간을 선택하고
  2. 신청을 할 수 있다.
  3. 신청 결과 Step으로 이동한다.
  4. 관리자의 승인 전까지 대기 중의 상태로 유지한다.
  5. 관리자가 승인 / 거절을 하면 FCM을 통해 유저에게 알리고 화면을 업데이트한다.
  6. 또는 유저가 Pull To Refresh나 새로고침 아이콘을 통해 화면을 업데이트한다.

문제는 5번에서 발생했습니다.

유저가 Refresh를 하는 순간 Refresh를 하는 아이콘이 사라지는 문제가 존재했습니다.

그럼 어느 부분이 문제였을까요?

대략적인 코드는 다음과 같습니다.

// Funnel
<Funnel>
    <Funnel.Step name='SUBMIT'>
        <신청화면 onNext={onClickSubmit} />
    </Funnel.Step>
    <Funnel.Step name='RESULT'>
        <결과화면 onPrev={onClickCancel} />
    </Funnel.Step>
</Funnel>

// AuthContext
const setUser = useCallback((newUser: User) => {
    dispatch({
        type: Types.UPDATE,
        payload: {
            user: newUser
        }
    });
}, []);

const memoizedValue = useMemo(
    () => ({
        isInitialized: state.isInitialized,
        user: state.user,
        logout,
        setUser
    }),
    [state.isInitialized, state.user, setUser, logout]
);

컴포넌트 reRendering의 조건

react의 컴포넌트가 리렌더링 되는 조건들은 정말 많은 것들이 있습니다.

그중 가장 일반적으로 사용하는 경우들을 살펴본다면 다음과 같습니다.

 

리렌더링 조건  
상태 변경 useState나 useReducer를 이용해 선언한 상태가 변경될 경우 리렌더링이 트리거 됨
props의 변경 부모 컴포넌트에서 전달되는 props가 변경되면 자식 컴포넌트는 리렌더링 됨
Context의 변경사항이 일어난 경우 컴포넌트가 구독 중인 Context의 값이 변경되면 구독중인 컴포넌트는 리렌더링 됨
Hooks의 의존성 배열 React Hooks의 useEffect, useCallback, useMemo 등이 가지는 의존성 배열의 값들이 변경될 경우 해당 Hook은 다시 실행됨 만약 React Hooks의 내부에 상태 변경 로직이 있을 경우 리렌더링이 일어날 수 있음
조건부 렌더링 ? 또는 &&, || 같이 조건식을 이용하여 조건부 렌더링을 할 경우 조건이 변경될 때마다 관련 컴포넌트가 리렌더링 됨
key 변경 리스트나 배열을 렌더링할 때 설정하는 고유한 key 값들이 변경되면 리렌더링 됨 (key가 인덱스로 설정되면 안좋은 이유)

 

이것을 보았을 때 이번에 고려해야 할 것들은

  1. 상태 변경
  2. 부모 컴포넌트의 리렌더링
  3. Context의 변경
  4. 조건부 렌더링

등이 있을 수 있겠네요

이 중에서 요번에 문제가 되었던 부분은 조건부 렌더링과 Context의 변경이었습니다.

  1. Funnel의 특징상 step에 따라 조건부로 렌더링이 되기 때문에 인스턴스를 새로 생성함
  2. Context user를 업데이트할 때 useReducer 내부에서 새로운 참조로 인해 리렌더링

근데 불변성을 지키면서 잘 데이터를 변경한 거 같은데 어떤 게 문제였을까요?

이 문제를 정확하게 알기 위해서는 JS 데이터 타입이 어떤 형태가 있고 참조에 대해서 알아보겠습니다.

 


JS의 데이터 타입

JS에는 기본 타입과 참조 타입이 존재합니다.

  1. 원시타입 (기본형)
  • 값이 절대 변하지 않는 불변성을 갖고 있기 때문에, 재할당 시 기존 값이 변하는 것처럼 보일지 몰라도 사실 새로운 메모리에 재할당한 값이 저장되고 변수가 가리키는 메모리가 달라진다.
  • 값을 통째로 복사해, 변수의 메모리에 담게 되기 때문에, 사본의 값이 변경되더라도, 원본에 영향을 주지 않는다.
  • number, string, boolean, null, undefined, symbol
  1. 참조타입
  • Object의 데이터 자체는 별도의 메모리 공간(heap)에 저장되며, 변수에 할당 시 데이터에 대한 주소(힙(Heap) 메모리의 주소값)가 저장되기 때문에, 자바스크립트 엔진이 변수가 가지고 있는 메모리 주소를 사용해서 변수의 값에 접근하게 된다.
  • 예시
     
  • 만일 let myArray = []라는 배열을 생성하면 위와 같은 일이 일어난다. 그림에서 볼 수 있듯이 원시타입의 값들은 값들이 직접적으로 저장되어 있지만, myArray (참조타입)는 Heap 메모리의 주소값이 저장되어 있다.
  • 참조 타입의 변수는 실제 데이터가 저장된 주소를 참조하기에 참조타입이라고 불린다. 따라서 사본의 객체의 프로퍼티를 변경했을 시 원본에 영향을 줄 수 있다.
  • object(Map, WeakMap, Set, WeakSet), array, function, date, regExp

참조 데이터의 저장


간단한 예시를 통해 알아보는 참조값의 불변성

이번에는 예시를 통해 이를 알아보도록 하겠습니다.

  • 만약 객체를 하나 생성하고 다른 변수에 할당하게 되면 이를 참조에 의한 할당 이라고도 합니다.
  • 아래와 같이 값이 동시에 바뀌게 됩니다.
const obj1 = { key: 'value' };
const obj2 = obj1;

obj2.key = 'newValue';

console.log(obj1.key);   // "newValue"
console.og(ob1 === obj2); // true
  • 하지만 이제 동일한 객체를 각각의 변수에 할당하고 값을 비교하게 되면이를 참조에 의한 비교라고도 합니다.
  • 이들은 다른 참조를 가지기 때문에 다르다고 판별됩니다.
const obj1 = { key: 'value' };
const obj2 = { key: 'value' };

console.log(obj1 === obj2);  // false

그리고 많이 들어봤을 불변성을 지키면서 코딩을 해야 한다!

여기서의 불변성이란 쉽게 말해서

원본 값에 대해서는 그대로 두고 복사본을 만들어 새롭게 값을 만들어서 쓰는 것입니다.

  • ES6의 Spread 연산자를 통해서 불변성을 쉽게 지키면서 코딩을 해나갈 수 있습니다.

이것도 예시를 통해 알아보자면

const numbers = [1, 2, 3, 4, 5];

// 원본 변경 (불변성 위반)
numbers.push(6);

// 불변성 지키기
const appendedNumbers = [...numbers, 6];
const user = {
    name: 'John',
    age: 30
};

// 원본 변경 (불변성 위반)
user.age = 31;

// 불변성 지키기
const updatedUser = { ...user, age: 31 };
그럼 왜 불변성을 지키는 게 중요할까요?

아까도 말했듯이 React의 기본 동작 원리 중 하나가 참조 비교를 통해 리렌더링의 여부를 결정하기 때문에

화면에 다른 데이터를 보여주고 싶다면 위와 같이 불변성을 지키면서 값을 변경해 주어야지

의도한 대로 동작하게 됩니다.

가장 흔히 사용하는 useState 자체도 불변성을 지키면서 데이터를 다룰 수 있는 유용한 Hook 중에 하나이죠.

(useState의 내부 동작 방식은 다음 포스트에서 한 번 다뤄보도록 하겠습니다.)

 


다시 문제로

이제 문제에 대해서 정확하게 알았습니다.

불변성을 지키면서 데이터를 변경하는 useReducer의 내부에서 계속해서 새로운 참조를 만들어내기 때문에

user에 변경사항이 없다 하더라도 이를 인지하지 못하고 memo의 참조값이 변하고 이게 context를 구독하는 컴포넌트들에게 전파되는 거죠.

사실 Web 환경에서의 react를 사용한다면 React Developer Tools를 이용해서 쉽게 디버깅이 가능 하지만

React Native 환경에서는 따로 지원해주지 않아 어떤 점이 문제였는지 찾는지 꽤 오랜 시간이 걸렸네요 🥲

Chrome Web Store

 

Chrome 웹 스토어

디스커버확장 프로그램테마

chromewebstore.google.com

그럼 이제 같은 user일 때는 참조를 변경하지 않겠다!라고 context에게 알려주어야겠죠

이럴 때는 lodashisEqual이나 깊은 비교를 하게 하는 유틸 함수를 작성해서 비교를 해주면 됩니다.

type DeepObject = { [key: string]: unknown } | null;

function deepEqual(obj1: DeepObject, obj2: DeepObject): boolean {
    if (obj1 === obj2) {
        return true;
    }

    if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
        return false;
    }

    const keys1 = Object.keys(obj1);
    const keys2 = Object.keys(obj2);

    if (keys1.length !== keys2.length) {
        return false;
    }

    for (let key of keys1) {
        if (!keys2.includes(key) || !deepEqual(obj1[key] as DeepObject, obj2[key] as DeepObject)) {
            return false;
        }
    }

    return true;
}
  • 당연하게도 객체의 깊이가 깊어질수록 비교하는데 시간이 늘어날 테니 정말 필요한 경우에만 적용하는 게 좋을 듯합니다.

마무리

이번에 예시와 함께 어떤 것이 리렌더링을 유발하고

JS의 데이터 타입과 불변성, 참조에 대해서 알아보았습니다. 사실 예제로 보여드린 코드 자체가

애초에 다른 방식으로 설계했더라면 이런 문제가 없었을 텐데 역시 처음에 각각에 페이지나 상태를 관리하는 방식을 잘 설계하고 전달하는 게 중요한 거 같습니다.

이번에는 정말 간단하게 리렌더링의 요소들만 알아보았기 때문에 다음에 최적화의 시점에서 리렌더링을 방지하는 기법들에 대해서 알아보도록 하겠습니다.

profile

기록의 습관화

@ww8007

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!