SPACE RUMI

Hi, I am rumi. Let's Splattack!

[STUDY] 스터디/React Docs

[7주차] 리액트 공식문서 한글 번역 : Removing Effect Dependencies - 이펙트 종속성 제거하기

백루미 2023. 4. 7. 20:19
반응형

Escape Hatches : 이펙트 종속성 제거하기

이펙트를 작성하면 린터는 이펙트의 종속성 목록에서 이펙트가 읽는 모든 반응형 값(예: prop 및 state)을 포함했는지 확인합니다. 이렇게 하면 이펙트가 컴포넌트의 최신 prop 및 state와 동기화 상태를 유지할 수 있습니다. 불필요한 종속성으로 인해 이펙트가 너무 자주 실행되거나 무한 루프를 생성할 수도 있습니다. 이 가이드를 따라 이펙트에서 불필요한 종속성을 검토하고 제거하세요.

 

학습 내용

  • 무한 Effect 종속성 루프를 수정하는 방법
  • 종속성을 제거할 때 해야 할 일
  • 이펙트에 "반응"하지 않고 이펙트에서 값을 읽는 방법
  • 오브젝트 및 함수 종속성을 피하는 방법과 이유
  • 종속성 린터를 억제하는 것이 위험한 이유와 그 대신 해야 할 일

 

종속성은 코드와 일치해야 합니다. 

이펙트를 작성할 때는 먼저 이펙트로 수행하고자 하는 작업을 시작하고 중지하는 방법을 지정합니다:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  	// ...
}

그런 다음 Effect 종속성을 비워두면([]) 린터가 올바른 종속성을 제안합니다:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- 잘못된 곳을 고치세요!
  return <h1>Welcome to the {roomId} room!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

린터가 말하는 대로 채우세요:

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ 모든 의존성이 선언됨
  // ...
}

이펙트는 반응형 값에 "반응"합니다. roomId는 반응형 값이므로(재렌더링으로 인해 변경될 수 있음), 린터는 이를 종속성으로 지정했는지 확인합니다. roomId가 다른 값을 받으면 React는 이펙트를 다시 동기화합니다. 이렇게 하면 채팅이 선택된 방에 연결된 상태를 유지하고 드롭다운에 '반응'합니다:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  return <h1>Welcome to the {roomId} room!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

 

종속성을 제거하려면 종속성이 아님을 증명하세요. 

이펙트의 종속성을 "선택"할 수 없다는 점에 유의하세요. 이펙트의 코드에서 사용되는 모든 반응형 값은 종속성 목록에 선언되어야 합니다. 종속성 목록은 주변 코드에 의해 결정됩니다:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) { // 유동적인 값
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // 이 Effect는 유동값을 참조합니다
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ 따라서 의존성 배열에 선언해줘야합니다.
  // ...
}

반응형 값에는 props와 컴포넌트 내부에서 직접 선언된 모든 변수 및 함수가 포함됩니다. roomId는 반응형 값이므로 의존성 목록에서 제거할 수 없습니다. 린터가 허용하지 않습니다:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // 🔴 React Hook useEffect에 누락된 종속성: 'roomId'가 있습니다.
  // ...
}

그리고 린터가 맞을 것입니다! roomId는 시간이 지남에 따라 변경될 수 있으므로 코드에 버그가 발생할 수 있습니다.

종속성을 제거하려면 해당 컴포넌트가 종속성이 될 필요가 없음을 린터에게 "증명"하세요. 예를 들어, roomId를 컴포넌트 밖으로 이동하여 리액티브하지 않고 재렌더링 시에도 변경되지 않음을 증명할 수 있습니다:

const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // 더이상 유동값이 아님

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // ✅ 모든 종속성이 선언됨
  // ...
}

이제 roomId는 유동적인 값이 아니므로(다시 렌더링할 때 변경할 수 없으므로) 종속성이 될 필요가 없습니다:

이제 빈([]) 종속성 목록을 지정할 수 있는 이유입니다. 이펙트는 더 이상 반응형 값에 의존하지 않으므로 컴포넌트의 프로퍼티나 상태가 변경될 때 이펙트를 다시 실행할 필요가 없습니다.

 

종속성을 변경하려면 코드를 변경하세요. 

워크플로에서 패턴을 발견했을 수도 있습니다:

  • 먼저 Effect의 코드나 반응형 값의 선언 방식을 변경합니다.
  • 그런 다음, 변경한 코드와 일치하도록 린터를 따라 종속성을 조정합니다.
  • 종속성 목록이 마음에 들지 않으면 첫 번째 단계로 돌아가서 코드를 다시 변경합니다.

마지막 부분이 중요합니다. 종속성을 변경하려면 먼저 주변 코드를 변경하세요. 종속성 목록은 이펙트의 코드에서 사용하는 모든 반응형 값의 목록이라고 생각하면 됩니다. 이 목록에 무엇을 넣을지는 사용자가 선택하지 않습니다. 이 목록은 코드를 설명합니다. 종속성 목록을 변경하려면 코드를 변경하면 됩니다.

이것은 방정식을 푸는 것처럼 느껴질 수 있습니다. 목표(예: 종속성 제거)를 정하고 그 목표에 맞는 코드를 '찾아야' 하는 것부터 시작할 수 있습니다. 모든 사람이 방정식을 푸는 것을 재미있어하는 것은 아니며, Effect를 작성할 때도 마찬가지입니다! 다행히도 아래에 시도해 볼 수 있는 일반적인 레시피 목록이 있습니다.

함정

기존 코드베이스가 있는 경우 이와 같이 린터를 억제하는 이펙트가 있을 수 있습니다:

useEffect(() => {
  // ...
  // 🔴 이런 식으로 린터를 억누르지 마세요:
  // eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

의존성이 코드와 일치하지 않으면 버그가 발생할 위험이 매우 높습니다. 린터를 억제하면 이펙트가 의존하는 값에 대해 React에 "거짓말"을 하게 됩니다.

대신 아래 기법을 사용하세요.

 

Deep Dive - 의존성 린터를 억제하는 것이 왜 그렇게 위험한가요?

린터를 억제하면 매우 직관적이지 않은 버그가 발생하여 찾아서 수정하기가 어렵습니다. 한 가지 예를 들어보겠습니다:

import { useState, useEffect } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  function onTick() {
	setCount(count + increment);
  }

  useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <h1>
        Counter: {count}
        <button onClick={() => setCount(0)}>Reset</button>
      </h1>
      <hr />
      <p>
        Every second, increment by:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}>–</button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}

"마운트할 때만" 이펙트를 실행하고 싶다고 가정해 봅시다. 빈([]) 종속성이 그렇게 한다는 것을 읽었으므로 린터를 무시하고 [] 종속성을 강제로 지정하기로 결정했습니다.

이 카운터는 두 개의 버튼으로 구성할 수 있는 양만큼 매초마다 증가해야 했습니다. 하지만 이 Effect가 아무 것도 의존하지 않는다고 React에 "거짓말"을 했기 때문에, React는 초기 렌더링부터 계속 onTick 함수를 사용합니다. 이 렌더링에서 카운트는 0이었고 증분은 1이었습니다. 그래서 이 렌더링의 onTick은 항상 매초마다 setCount(0 + 1)을 호출하고 항상 1이 표시됩니다. 이와 같은 버그는 여러 컴포넌트에 분산되어 있을 때 수정하기가 더 어렵습니다.

린터를 무시하는 것보다 더 좋은 해결책은 항상 있습니다! 이 코드를 수정하려면 의존성 목록에 onTick을 추가해야 합니다. (간격을 한 번만 설정하도록 하려면 onTick을 Effect Event로 만드세요.)

의존성 린트 오류는 컴파일 오류로 처리하는 것이 좋습니다. 이를 억제하지 않으면 이와 같은 버그가 발생하지 않습니다. 이 페이지의 나머지 부분에서는 이 경우와 다른 경우에 대한 대안을 설명합니다.

 

불필요한 종속성 제거하기 

코드를 반영하기 위해 Effect의 종속성을 조정할 때마다 종속성 목록을 살펴보십시오. 이러한 종속성 중 하나라도 변경되면 이펙트가 다시 실행되는 것이 합리적일까요? 때때로 대답은 "아니오"입니다:

  • 다른 조건에서 Effect의 다른 부분을 다시 실행하고 싶을 수도 있습니다.
  • 일부 종속성의 변경 사항에 '반응'하는 대신 최신 값만 읽고 싶을 수도 있습니다.
  • 종속성은 객체나 함수이기 때문에 의도치 않게 너무 자주 변경될 수 있습니다.

올바른 해결책을 찾으려면 Effect에 대한 몇 가지 질문에 답해야 합니다. 몇 가지 질문을 살펴봅시다.

 

이 코드를 이벤트 핸들러로 옮겨야 할까요? 

가장 먼저 고려해야 할 것은 이 코드가 이펙트가 되어야 하는지 여부입니다.

양식을 상상해 보세요. 제출 시 submitted 상태 변수를 true로 설정합니다. POST 요청을 보내고 알림을 표시해야 합니다. 이 로직을 제출됨이 참일 때 "반응"하는 Effect 안에 넣었습니다:

function Form() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      // 🔴 피하기: Effect 내부의 이벤트별 로직
      post('/api/register');
      showNotification('Successfully registered!');
    }
  }, [submitted]);

  function handleSubmit() {
    setSubmitted(true);
  }

  // ...
}

나중에 현재 theme에 따라 알림 메시지의 스타일을 지정하고 싶으므로 현재 테마를 읽습니다. 테마는 컴포넌트 본문에서 선언되므로 반응형 값이므로 종속성으로 추가합니다:

function Form() {
  const [submitted, setSubmitted] = useState(false);
  const theme = useContext(ThemeContext);

  useEffect(() => {
    if (submitted) {
      // 🔴 피하기: Effect 내부에 이벤트별 로직
      post('/api/register');
      showNotification('Successfully registered!', theme);
    }
  }, [submitted, theme]); // ✅ 모든 종속성이 선언됨

  function handleSubmit() {
    setSubmitted(true);
  }  

  // ...
}

이렇게 하면 버그가 발생하게 됩니다. 양식을 먼저 제출한 다음 어두운 테마와 밝은 테마 사이를 전환한다고 가정해 보세요 theme가 변경되고 이펙트가 다시 실행되어 동일한 알림이 다시 표시됩니다!

여기서 문제는 이것이 애초에 이펙트가 아니어야 한다는 것입니다. 이 POST 요청을 보내고 특정 상호작용인 양식 제출에 대한 응답으로 알림을 표시하려고 합니다. 특정 상호작용에 대한 응답으로 일부 코드를 실행하려면 해당 로직을 해당 이벤트 핸들러에 직접 넣으세요:

function Form() {
  const theme = useContext(ThemeContext);

  function handleSubmit() {
    // ✅ 좋습니다: 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
    post('/api/register');
    showNotification('Successfully registered!', theme);
  }  

  // ...
}

이제 코드가 이벤트 핸들러에 있으므로 반응형 코드가 아니므로 사용자가 양식을 제출할 때만 실행됩니다. 이벤트 핸들러와 효과 중에서 선택하는 방법과 불필요한 효과를 삭제하는 방법에 대해 자세히 알아보세요.

 

이펙트가 관련 없는 여러 가지 일을 하고 있나요? 

다음으로 스스로에게 물어봐야 할 질문은 이펙트가 관련 없는 여러 가지 작업을 수행하고 있는지 여부입니다.

사용자가 도시와 지역을 선택해야 하는 배송 양식을 만든다고 가정해 보겠습니다. 선택한 국가에 따라 서버에서 도시 목록을 가져와 드롭다운에 표시합니다:

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  const [city, setCity] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [country]); // ✅ 모든 종속성이 선언됨

  // ...

이펙트에서 데이터를 가져오는 좋은 예시입니다. country prop에 따라 cities state를 네트워크와 동기화하고 있습니다. ShippingForm이 표시되는 즉시 그리고 country가 변경될 때마다(어떤 상호작용이 원인이든 상관없이) 데이터를 가져와야 하므로 이벤트 핸들러에서는 이 작업을 수행할 수 없습니다.

이제 현재 선택된 도시의 지역을 가져와야 하는 도시 지역에 대한 두 번째 셀렉트박스를 추가한다고 가정해 보겠습니다. 동일한 Effect 안에 지역 목록에 대한 두 번째 가져오기 호출을 추가하는 것으로 시작할 수 있습니다:

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    // 🔴 피하기: 하나의 Effect가 두 개의 독립적인 프로세스를 동기화합니다.
    if (city) {
      fetch(`/api/areas?city=${city}`)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setAreas(json);
          }
        });
    }
    return () => {
      ignore = true;
    };
  }, [country, city]); // ✅ All dependencies declared

  // ...

하지만 이제 이펙트가 city 상태 변수를 사용하므로 종속성 목록에 city를 추가해야 했습니다. 이로 인해 사용자가 다른 도시를 선택하면 Effect가 다시 실행되어 fetchCities(country)를 호출하는 문제가 발생했습니다. 결과적으로 불필요하게 도시 목록을 여러 번 다시 불러오게 됩니다.

이 코드의 문제점은 서로 관련이 없는 두 가지를 동기화한다는 것입니다:

  • country props를 기반으로 cities state를 네트워크에 동기화하려고 합니다.
  • city 상태를 기준으로 areas 상태를 네트워크에 동기화하려고 합니다.

로직을 두 개의 이펙트로 분할하고, 각 이펙트는 동기화해야 하는 props에 반응합니다:

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [country]); // ✅ All dependencies declared

  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);
  useEffect(() => {
    if (city) {
      let ignore = false;
      fetch(`/api/areas?city=${city}`)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setAreas(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [city]); // ✅ All dependencies declared

  // ...

이제 첫 번째 Effect는 country가 변경될 때만 다시 실행되고, 두 번째 Effect는 city가 변경될 때 다시 실행됩니다. 목적에 따라 분리했으니, 서로 다른 두 가지가 두 개의 개별 Effect에 의해 동기화됩니다. 두 개의 개별 Effect에는 두 개의 개별 종속성 목록이 있으므로 의도하지 않게 서로를 트리거하지 않습니다.

최종 코드는 원본보다 길어지지만 이러한 Effect를 분할하는 것이 여전히 정확합니다. 각 Effect는 독립적인 동기화 프로세스를 나타내야 합니다. 이 예제에서는 한 Effect를 삭제해도 다른 Effect의 로직이 깨지지 않습니다. 즉, 서로 다른 것을 동기화하므로 분할하는 것이 좋습니다. 중복이 걱정된다면 반복되는 로직을 커스텀 Hook으로 추출하여 이 코드를 개선할 수 있습니다.

 

다음 상태를 계산하기 위해 어떤 상태를 읽고 있나요? 

이 Effect는 새 메시지가 도착할 때마다 새로 생성된 배열로 메시지 상태 변수를 업데이트합니다:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages([...messages, receivedMessage]);
    });
    // ...

메시지 변수를 사용하여 모든 기존 메시지로 시작하는 새 배열을 생성하고 마지막에 새 메시지를 추가합니다. 하지만 메시지는 Effect에서 읽는 반응형 값이므로 종속성이어야 합니다:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages([...messages, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId, messages]); // ✅ All dependencies declared
  // ...

그리고 messages 를 종속성으로 만들면 문제가 발생합니다.

메시지를 수신할 때마다 setMessages()는 컴포넌트가 수신된 메시지를 포함하는 새 메시지 배열로 다시 렌더링하도록 합니다. 하지만 이 이펙트는 이제 메시지에 종속되므로 이펙트도 다시 동기화됩니다. 따라서 새 메시지가 올 때마다 채팅이 다시 연결됩니다. 사용자가 원하지 않을 것입니다!

이 문제를 해결하려면 이펙트 내에서 메시지를 읽지 마세요. 대신 업데이터 함수를 setMessages에 전달하세요:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

이제 Effect가 메시지 변수를 전혀 읽지 않는 것을 알 수 있습니다. msgs => [...msgs, receivedMessage]와 같은 업데이터 함수만 전달하면 됩니다. React는 업데이터 함수를 대기열에 넣고 다음 렌더링 중에 msgs 인수를 제공합니다. 이 때문에 Effect 자체는 더 이상 messages 에 의존할 필요가 없습니다. 이 수정으로 인해 채팅 메시지를 수신해도 더 이상 채팅이 다시 연결되지 않습니다.

 

값의 변경에 '반응'하지 않고 값을 읽고 싶으신가요? 

개발 중
이 섹션에서는 아직 안정된 버전의 React로 출시되지 않은 실험적인 API에 대해 설명합니다.

사용자가 새 메시지를 수신할 때 isMuted가 참이 아닌 경우 사운드를 재생하고 싶다고 가정해 보겠습니다:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]);
      if (!isMuted) {
        playSound();
      }
    });
    // ...

이제 이펙트의 코드에서 isMuted를 사용하므로 종속 요소에 추가해야 합니다:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]);
      if (!isMuted) {
        playSound();
      }
    });
    return () => connection.disconnect();
  }, [roomId, isMuted]); // ✅ All dependencies declared
  // ...

문제는 사용자가 Muted 토글을 누르는 등 isMuted가 변경될 때마다 이펙트가 다시 동기화되고 채팅에 다시 연결된다는 점입니다. 이는 바람직한 사용자 경험이 아닙니다! (이 예에서는 린터를 비활성화해도 작동하지 않습니다. 그렇게 하면 isMuted가 이전 값으로 '고착'됩니다.)

이 문제를 해결하려면 이펙트에서 반응해서는 안 되는 로직을 추출해야 합니다. 이 Effect가 isMuted의 변경에 "반응"하지 않기를 원합니다. 이 반응하지 않는 로직을 이펙트 이벤트로 옮기면 됩니다:

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

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  const onMessage = useEffectEvent(receivedMessage => {
    setMessages(msgs => [...msgs, receivedMessage]);
    if (!isMuted) {
      playSound();
    }
  });

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

Effect Event를 사용하면 이펙트를 반응형 부분(roomId와 같은 반응형 값과 그 변경에 "반응"해야 하는)과 비반응형 부분(onMessage가 isMuted를 읽는 것처럼 최신 값만 읽는)으로 나눌 수 있습니다. 이제 Effect Event 내에서 isMuted를 읽었으므로 Effect의 종속성이 될 필요가 없습니다. 그 결과, "Muted" 설정을 켜고 끌 때 채팅이 다시 연결되지 않아 원래 문제가 해결되었습니다!

 

Props에서 이벤트 핸들러 래핑하기 

컴포넌트가 이벤트 핸들러를 props로 받을 때 비슷한 문제가 발생할 수 있습니다:

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onReceiveMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId, onReceiveMessage]); // ✅ All dependencies declared
  // ...

부모 컴포넌트가 렌더링할 때마다 다른 onReceiveMessage 함수를 전달한다고 가정해 보겠습니다:

<ChatRoom
  roomId={roomId}
  onReceiveMessage={receivedMessage => {
    // ...
  }}
/>

onReceiveMessage는 종속성이므로 부모가 다시 렌더링할 때마다 이펙트가 다시 동기화될 수 있습니다. 그러면 채팅에 다시 연결됩니다. 이 문제를 해결하려면 호출을 Effect Event로 래핑하세요:

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  const onMessage = useEffectEvent(receivedMessage => {
    onReceiveMessage(receivedMessage);
  });

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

이펙트 이벤트는 반응형 이벤트가 아니므로 종속성으로 지정할 필요가 없습니다. 따라서 부모 컴포넌트가 다시 렌더링할 때마다 다른 함수를 전달하더라도 채팅이 더 이상 다시 연결되지 않습니다.

반응형 코드와 비반응형 코드 분리하기 

이 예제에서는 roomId가 변경될 때마다 방문을 기록하려고 합니다. 모든 로그에 현재 알림 수를 포함시키고 싶지만 알림 수 변경으로 로그 이벤트가 트리거되는 것은 원하지 않습니다.

해결책은 다시 비반응형 코드를 Effect Event로 분리하는 것입니다:

function Chat({ roomId, notificationCount }) {
  const onVisit = useEffectEvent(visitedRoomId => {
    logVisit(visitedRoomId, notificationCount);
  });

  useEffect(() => {
    onVisit(roomId);
  }, [roomId]); // ✅ All dependencies declared
  // ...
}

로직이 roomId와 관련하여 반응하기를 원하므로 Effect 내부에서 roomId를 읽습니다. 그러나 알림 수를 변경하여 추가 방문을 기록하는 것은 원하지 않으므로 Effect Event 내부에서 알림 수를 읽습니다. Effect Event를 사용하여 효과에서 최신 props와 state를 읽는 방법에 대해 자세히 알아보세요.

 

일부 반응 값이 의도치 않게 변경되나요? 

이펙트가 특정 값에 '반응'하기를 원하지만, 그 값이 원하는 것보다 더 자주 변경되어 사용자의 관점에서 실제 변경 사항을 반영하지 못할 수도 있습니다. 예를 들어 컴포넌트 본문에 옵션 객체를 생성한 다음 이펙트 내부에서 해당 객체를 읽는다고 가정해 보겠습니다:

function ChatRoom({ roomId }) {
  // ...
  const options = {
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    // ...

options 객체는 컴포넌트 본문에서 선언되므로 반응형 값입니다. Effect 내에서 이와 같은 반응형 값을 읽으면 종속성으로 선언합니다. 이렇게 하면 이펙트가 변경 사항에 "반응"하게 됩니다:

  // ...
  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // ✅ All dependencies declared
  // ...

종속성으로 선언하는 것이 중요합니다! 이렇게 하면 예를 들어 roomId가 변경되면 Effect가 새 옵션으로 채팅에 다시 연결됩니다. 하지만 위 코드에도 문제가 있습니다. 이를 확인하려면 아래 샌드박스에 입력을 입력하고 콘솔에서 어떤 일이 발생하는지 살펴보세요:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  // 문제를 보여주기 위해 일시적으로 린터를 비활성화합니다.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const options = {
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

위의 샌드박스에서 입력은 메시지 상태 변수만 업데이트합니다. 사용자 입장에서는 이것이 채팅 연결에 영향을 미치지 않아야 합니다. 하지만 메시지를 업데이트할 때마다 컴포넌트가 다시 렌더링됩니다. 컴포넌트가 다시 렌더링되면 컴포넌트 내부의 코드가 처음부터 다시 실행됩니다.

채팅방 컴포넌트가 다시 렌더링될 때마다 새로운 옵션 객체가 처음부터 새로 생성됩니다. React는 옵션 객체가 마지막 렌더링 중에 생성된 옵션 객체와 다른 객체라고 인식합니다. 그렇기 때문에 (옵션에 따라 달라지는) Effect를 다시 동기화하고 사용자가 입력할 때 채팅이 다시 연결됩니다.

이 문제는 객체와 함수에만 영향을 줍니다. 자바스크립트에서는 새로 생성된 객체와 함수가 다른 모든 객체와 구별되는 것으로 간주됩니다. 그 안에 있는 콘텐츠가 동일할 수 있다는 것은 중요하지 않습니다!

// During the first render
const options1 = { serverUrl: 'https://localhost:1234', roomId: 'music' };

// During the next render
const options2 = { serverUrl: 'https://localhost:1234', roomId: 'music' };

// These are two different objects!
console.log(Object.is(options1, options2)); // false

오브젝트 및 함수 종속성으로 인해 이펙트가 필요 이상으로 자주 다시 동기화될 수 있습니다.

그렇기 때문에 가능하면 객체와 함수를 이펙트의 종속성으로 사용하지 않는 것이 좋습니다. 대신 컴포넌트 외부나 이펙트 내부로 이동하거나 원시 값을 추출해 보세요.

 

정적 객체와 함수를 컴포넌트 외부로 이동하기 

오브젝트가 props와 state에 의존하지 않는 경우 해당 오브젝트를 컴포넌트 외부로 이동할 수 있습니다:

const options = {
  serverUrl: 'https://localhost:1234',
  roomId: 'music'
};

function ChatRoom() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, []); // ✅ All dependencies declared
  // ...

이렇게 하면 린터가 반응하지 않는다는 것을 증명할 수 있습니다. 재렌더링의 결과로 변경될 수 없으므로 종속성이 될 필요가 없습니다. 이제 ChatRoom을 다시 렌더링해도 이펙트가 다시 동기화되지 않습니다.

이는 함수에도 적용됩니다:

function createOptions() {
  return {
    serverUrl: 'https://localhost:1234',
    roomId: 'music'
  };
}

function ChatRoom() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []); // ✅ All dependencies declared
  // ...

createOptions는 컴포넌트 외부에서 선언되므로 반응형 값이 아닙니다. 그렇기 때문에 Effect의 종속성에 지정할 필요가 없으며, Effect가 다시 동기화되지 않는 이유이기도 합니다.

Effect 내에서 동적 객체 및 함수 이동 

객체가 재렌더링의 결과로 변경될 수 있는 반응형 값에 의존하는 경우(예: roomId Props), 컴포넌트 외부로 끌어낼 수 없습니다. 하지만 해당 오브젝트의 생성을 이펙트 코드 내부로 이동할 수는 있습니다:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

이제 options가 이펙트 내부에서 선언되었으므로 더 이상 이펙트의 종속성이 아닙니다. 대신 Effect에서 사용하는 유일한 반응형 값은 roomId입니다. roomId는 객체나 함수가 아니기 때문에 의도치 않게 달라지지 않을 것이라고 확신할 수 있습니다. 자바스크립트에서 숫자와 문자열은 그 내용에 따라 비교됩니다:

// During the first render
const roomId1 = 'music';

// During the next render
const roomId2 = 'music';

// These two strings are the same!
console.log(Object.is(roomId1, roomId2)); // true

이 수정 덕분에 입력을 수정해도 더 이상 채팅이 다시 연결되지 않습니다:

그러나 예상대로 roomId 드롭다운을 변경하면 다시 연결됩니다.
이는 함수에서도 마찬가지입니다:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() {
      return {
        serverUrl: serverUrl,
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

이펙트 안에서 로직을 그룹화하기 위해 자신만의 함수를 작성할 수 있습니다. 이펙트 내부에서 선언하는 한, 반응형 값이 아니므로 이펙트의 종속성이 될 필요가 없습니다.

객체에서 원시값 읽기 

때로는 props에서 객체를 받을 수도 있습니다:

function ChatRoom({ options }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // ✅ All dependencies declared
  // ...

여기서 위험은 렌더링 중에 부모 컴포넌트가 오브젝트를 생성한다는 것입니다:

<ChatRoom
  roomId={roomId}
  options={{
    serverUrl: serverUrl,
    roomId: roomId
  }}
/>

이렇게 하면 부모 컴포넌트가 다시 렌더링할 때마다 이펙트가 다시 연결됩니다. 이 문제를 해결하려면 이펙트 외부의 객체에서 정보를 읽고 객체 및 함수 종속성을 피하십시오:

function ChatRoom({ options }) {
  const [message, setMessage] = useState('');

  const { roomId, serverUrl } = options;
  useEffect(() => {
    const connection = createConnection({
      roomId: roomId,
      serverUrl: serverUrl
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // ✅ All dependencies declared
  // ...

로직은 약간 반복적입니다(이펙트 외부의 객체에서 일부 값을 읽은 다음 이펙트 내부에 동일한 값을 가진 객체를 만듭니다). 하지만 이펙트가 실제로 어떤 정보에 의존하는지 매우 명확하게 알 수 있습니다. 부모 컴포넌트에 의해 의도치 않게 객체가 다시 생성된 경우 채팅이 다시 연결되지 않습니다. 그러나 options.roomId 또는 options.serverUrl이 실제로 다른 경우 채팅이 다시 연결됩니다.

함수에서 기본값 계산하기 

함수에 대해서도 동일한 접근 방식을 사용할 수 있습니다. 예를 들어 부모 컴포넌트가 함수를 전달한다고 가정해 보겠습니다:

<ChatRoom
  roomId={roomId}
  getOptions={() => {
    return {
      serverUrl: serverUrl,
      roomId: roomId
    };
  }}
/>

종속성을 만들지 않으려면(그리고 다시 렌더링할 때 다시 연결되는 것을 방지하려면) 이펙트 외부에서 호출하세요. 이렇게 하면 객체가 아니며 Effect 내부에서 읽을 수 있는 roomId 및 serverUrl 값을 얻을 수 있습니다:

function ChatRoom({ getOptions }) {
  const [message, setMessage] = useState('');

  const { roomId, serverUrl } = getOptions();
  useEffect(() => {
    const connection = createConnection({
      roomId: roomId,
      serverUrl: serverUrl
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // ✅ All dependencies declared
  // ...

이는 렌더링 중에 호출해도 안전하므로 순수 함수에서만 작동합니다. 함수가 이벤트 핸들러이지만 변경 사항으로 인해 이펙트가 다시 동기화되는 것을 원하지 않는 경우, 대신 이펙트 이벤트에 함수를 래핑하세요.

 

요약

  • 종속성은 항상 코드와 일치해야 합니다.
  • 종속성이 마음에 들지 않으면 코드를 수정해야 합니다.
  • 린터를 억제하면 매우 혼란스러운 버그가 발생하므로 항상 피해야 합니다.
  • 종속성을 제거하려면 해당 종속성이 필요하지 않다는 것을 린터에게 "증명"해야 합니다.
  • 특정 상호작용에 대한 응답으로 일부 코드가 실행되어야 하는 경우 해당 코드를 이벤트 핸들러로 이동하세요.
  • 이펙트의 다른 부분이 다른 이유로 다시 실행되어야 하는 경우 여러 개의 이펙트로 분할하세요.
  • 이전 상태를 기반으로 일부 상태를 업데이트하려면 업데이터 함수를 전달하세요.
  • "반응"하지 않고 최신 값을 읽으려면 Effect에서 Effect Event를 추출하세요.
  • 자바스크립트에서 객체와 함수는 서로 다른 시간에 생성된 경우 서로 다른 것으로 간주됩니다.
  • 객체와 함수의 종속성을 피하세요. 컴포넌트 외부나 Effect 내부로 이동하세요.

 

과제 1 / 4: 재설정 간격 수정하기 

이 Effect는 1초마다 틱하는 간격을 설정합니다. 틱할 때마다 간격이 파괴되었다가 다시 생성되는 것처럼 보이는 이상한 현상이 발생하고 있습니다. 간격이 계속 다시 생성되지 않도록 코드를 수정하세요.

https://codesandbox.io/s/fix-a-resetting-interval-lwpirs?file=/App.js

 

과제 2/4: 다시 트리거되는 애니메이션 수정하기 

이 예에서는 'Show'를 누르면 환영 메시지가 페이드 인합니다. 애니메이션은 1초 정도 걸립니다. "Remove"를 누르면 환영 메시지가 즉시 사라집니다. 페이드인 애니메이션의 로직은 animation.js 파일에서 일반 JavaScript 애니메이션 루프로 구현됩니다. 이 로직을 변경할 필요는 없습니다. 타사 라이브러리로 취급하면 됩니다. 이펙트는 DOM 노드에 대한 FadeInAnimation 인스턴스를 생성한 다음 start(duration) 또는 stop()을 호출하여 애니메이션을 제어합니다. 지속 시간은 슬라이더로 제어합니다. 슬라이더를 조정하고 애니메이션이 어떻게 변하는지 확인하세요.

이 코드는 이미 작동하지만 변경하고 싶은 부분이 있습니다. 현재 지속 시간 상태 변수를 제어하는 슬라이더를 움직이면 애니메이션이 다시 트리거됩니다. 이펙트가 지속 시간 변수에 "반응"하지 않도록 동작을 변경합니다. "Show"를 누르면 효과는 슬라이더의 현재 지속 시간을 사용해야 합니다. 그러나 슬라이더 자체를 움직여도 그 자체로 애니메이션이 다시 트리거되어서는 안 됩니다.

https://codesandbox.io/s/fix-a-retriggering-animation-b3r6jf?file=/App.js

 

과제 3/4: 다시 연결되는 채팅 수정하기 

이 예에서는 'Toggle theme'을 누를 때마다 채팅이 다시 연결됩니다. 왜 이런 일이 발생하나요? 서버 URL을 편집하거나 다른 대화방을 선택할 때만 채팅이 다시 연결되도록 실수를 수정하세요.

chat.js를 외부 타사 라이브러리로 취급: API를 확인하기 위해 참조할 수는 있지만 편집해서는 안 됩니다.

https://codesandbox.io/s/fix-a-reconnecting-chat-gb3lut?file=/ChatRoom.js

 

과제 4/4: 다시  연결되는 채팅 또 수정하기 

이 예는 암호화를 사용하거나 사용하지 않고 채팅에 연결합니다. 확인란을 토글하면 암호화가 켜져 있을 때와 꺼져 있을 때 콘솔에 다른 메시지가 표시되는 것을 확인할 수 있습니다. 대화방을 변경해 보세요. 그런 다음 테마를 토글해 보세요. 대화방에 연결되면 몇 초마다 새 메시지를 받게 됩니다. 메시지의 색상이 선택한 테마와 일치하는지 확인합니다.

이 예에서는 테마를 변경하려고 할 때마다 채팅이 다시 연결됩니다. 이 문제를 수정합니다. 수정 후에는 테마를 변경해도 채팅이 다시 연결되지 않지만, 암호화 설정을 토글하거나 대화방을 변경하면 다시 연결됩니다.

chat.js의 코드를 변경하지 마세요. 그 외에는 동일한 동작을 초래하는 한 어떤 코드든 변경할 수 있습니다. 예를 들어, 전달되는 props를 변경하는 것이 도움이 될 수 있습니다.

https://codesandbox.io/s/fix-a-reconnecting-chat-again-cv4x6u?file=/ChatRoom.js

 

발표요약)

1. 린터 에러가 발생하면 항상 코드에 문제가 있음을 인지하고 fix 하자.

2. 종속성이 없다는것을 린터에게 알려주려면 Effect내에서 참조하는 값을 컴포넌트 바깥으로 뺀다.

3. 함수와 객체는 웬만하면 종속성으로 쓰지말고, 쓰려거든 필요한 값을 구조분해로 원시값을 추출해서 써라. 추출한 값은 의존성배열에 넣는것을 잊지말자.
ex)   const { roomId, serverUrl } = getOptions();

4. 다른방법으로는 Effect Event로 분리하는방법이 있다.

반응형