Lifecycle of Reactive Effects

김동현·2026년 3월 16일

리액티브 Effect의 생명주기

도입

Effect는 컴포넌트와는 다른 생명주기를 가지고 있어요. 컴포넌트는 마운트되거나, 업데이트되거나, 언마운트될 수 있죠. 하지만 Effect는 딱 두 가지만 할 수 있어요: 무언가의 동기화를 시작하는 것, 그리고 나중에 그 동기화를 중지하는 것. 이 사이클은 여러분의 Effect가 시간이 지나면서 변하는 props와 state에 의존하고 있다면 여러 번 반복될 수 있어요. React는 여러분이 Effect의 의존성을 올바르게 지정했는지 확인해주는 린터 규칙을 제공해요. 이렇게 하면 Effect가 최신 props와 state에 동기화된 상태를 유지할 수 있답니다.

이 페이지에서 배울 내용

  • Effect의 생명주기가 컴포넌트의 생명주기와 어떻게 다른지
  • 각각의 개별 Effect를 독립적으로 생각하는 방법
  • Effect가 언제 재동기화되어야 하는지, 그리고 왜 그런지
  • Effect의 의존성이 어떻게 결정되는지
  • 값이 "리액티브하다"는 것이 무슨 의미인지
  • 빈 의존성 배열이 무엇을 의미하는지
  • React가 린터를 통해 의존성이 올바른지 어떻게 검증하는지
  • 린터와 의견이 다를 때 어떻게 해야 하는지

Effect의 생명주기

모든 React 컴포넌트는 동일한 생명주기를 거쳐요:

  • 컴포넌트가 화면에 추가되면 마운트(mount) 돼요.
  • 컴포넌트가 새로운 props나 state를 받으면 업데이트(update) 돼요. 보통 사용자의 상호작용에 반응해서 일어나죠.
  • 컴포넌트가 화면에서 제거되면 언마운트(unmount) 돼요.

이건 컴포넌트를 생각하는 좋은 방법이지만, Effect에 대해서는 아니에요. 대신에, 각 Effect를 컴포넌트의 생명주기와 독립적으로 생각해보세요. Effect는 현재 props와 state에 외부 시스템을 동기화하는 방법을 설명해요. 코드가 변경되면 동기화가 더 자주 또는 덜 자주 일어나야 할 수도 있거든요.

이 포인트를 설명하기 위해, 컴포넌트를 채팅 서버에 연결하는 이 Effect를 살펴볼게요:

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

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

Effect의 본문은 동기화를 시작하는 방법을 지정해요:

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

Effect가 반환하는 클린업 함수는 동기화를 중지하는 방법을 지정해요:

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

직감적으로는 React가 컴포넌트가 마운트될 때 동기화를 시작하고 컴포넌트가 언마운트될 때 동기화를 중지할 거라고 생각할 수 있어요. 하지만 이게 끝이 아니에요! 때로는 컴포넌트가 마운트된 상태에서 동기화를 시작하고 중지하는 것을 여러 번 해야 할 수도 있거든요.

이게 필요한지, 언제 이런 일이 발생하는지, 그리고 어떻게 이 동작을 제어할 수 있는지 살펴봐요.

참고

어떤 Effect들은 클린업 함수를 전혀 반환하지 않기도 해요. 대부분의 경우 클린업 함수를 반환하고 싶을 거예요. 하지만 반환하지 않으면 React는 빈 클린업 함수를 반환한 것처럼 동작해요.

동기화가 여러 번 일어나야 하는 이유

ChatRoom 컴포넌트가 사용자가 드롭다운에서 선택하는 roomId prop을 받는다고 상상해보세요. 처음에 사용자가 "general" 방을 roomId로 선택했다고 해볼게요. 앱은 "general" 채팅방을 표시해요:

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

function ChatRoom({ roomId /* "general" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

UI가 표시된 후에 React는 동기화를 시작하기 위해 Effect를 실행해요. "general" 방에 연결하죠:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
  }, [roomId]);
  // ...

여기까지는 좋아요.

그런데 나중에 사용자가 드롭다운에서 다른 방을 선택해요 (예를 들어, "travel"). React는 먼저 UI를 업데이트해요:

function ChatRoom({ roomId /* "travel" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

다음에 무슨 일이 일어나야 할지 생각해보세요. 사용자는 UI에서 "travel"이 선택된 채팅방인 걸 봐요. 그런데 지난번에 실행된 Effect는 여전히 "general" 방에 연결되어 있어요. roomId prop이 변경됐기 때문에, 그때 Effect가 했던 일 ("general" 방에 연결하기)은 더 이상 UI와 일치하지 않아요.

이 시점에서 React가 두 가지를 해줬으면 해요:

  1. 이전 roomId와의 동기화를 중지해요 ("general" 방에서 연결 해제)
  2. 새로운 roomId와의 동기화를 시작해요 ("travel" 방에 연결)

다행히도, 여러분은 이미 React에게 이 두 가지를 하는 방법을 가르쳐줬어요! Effect의 본문은 동기화를 시작하는 방법을 지정하고, 클린업 함수는 동기화를 중지하는 방법을 지정하죠. React가 해야 할 일은 이것들을 올바른 순서로, 올바른 props와 state로 호출하는 것뿐이에요. 정확히 어떻게 일어나는지 봐봐요.

React가 Effect를 재동기화하는 방법

ChatRoom 컴포넌트가 roomId prop의 새로운 값을 받았다는 걸 기억하세요. 이전에는 "general"이었고, 지금은 "travel"이에요. React는 여러분을 다른 방에 다시 연결하기 위해 Effect를 재동기화해야 해요.

동기화를 중지하기 위해, React는 "general" 방에 연결한 후에 Effect가 반환한 클린업 함수를 호출해요. roomId"general"이었으니까, 클린업 함수는 "general" 방에서 연결을 해제해요:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
    // ...

그다음 React는 이번 렌더링에서 여러분이 제공한 Effect를 실행해요. 이번에는 roomId"travel"이니까 "travel" 채팅방에 동기화를 시작해요 (결국에는 그것의 클린업 함수도 호출될 때까지):

function ChatRoom({ roomId /* "travel" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room
    connection.connect();
    // ...

이렇게 해서, 여러분은 이제 사용자가 UI에서 선택한 것과 같은 방에 연결된 거예요. 위기를 모면했네요!

컴포넌트가 다른 roomId로 리렌더링될 때마다, Effect가 재동기화돼요. 예를 들어, 사용자가 roomId"travel"에서 "music"으로 변경한다고 해볼게요. React는 다시 클린업 함수를 호출해서 Effect의 동기화를 중지해요 ("travel" 방에서 연결 해제). 그리고 새로운 roomId prop으로 본문을 실행해서 다시 동기화를 시작해요 ("music" 방에 연결).

마지막으로, 사용자가 다른 화면으로 가면 ChatRoom이 언마운트돼요. 이제 더 이상 연결을 유지할 필요가 없어요. React는 마지막으로 Effect의 동기화를 중지하고 "music" 채팅방에서 연결을 해제해요.

Effect의 관점에서 생각하기

ChatRoom 컴포넌트의 관점에서 일어난 모든 것을 정리해볼게요:

  1. ChatRoomroomId"general"로 설정된 채로 마운트됨
  2. ChatRoomroomId"travel"로 설정된 채로 업데이트됨
  3. ChatRoomroomId"music"으로 설정된 채로 업데이트됨
  4. ChatRoom이 언마운트됨

컴포넌트 생명주기의 각 시점에서 Effect는 다른 일을 했어요:

  1. Effect가 "general" 방에 연결함
  2. Effect가 "general" 방에서 연결 해제하고 "travel" 방에 연결함
  3. Effect가 "travel" 방에서 연결 해제하고 "music" 방에 연결함
  4. Effect가 "music" 방에서 연결 해제함

이제 Effect 자체의 관점에서 무슨 일이 일어났는지 생각해볼게요:

  useEffect(() => {
    // Your Effect connected to the room specified with roomId...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      // ...until it disconnected
      connection.disconnect();
    };
  }, [roomId]);

이 코드의 구조를 보면, 일어난 일을 겹치지 않는 시간 구간의 연속으로 볼 수 있어요:

  1. Effect가 "general" 방에 연결함 (연결 해제될 때까지)
  2. Effect가 "travel" 방에 연결함 (연결 해제될 때까지)
  3. Effect가 "music" 방에 연결함 (연결 해제될 때까지)

이전에는 컴포넌트의 관점에서 생각하고 있었죠. 컴포넌트의 관점에서 보면, Effect를 "렌더링 후"나 "언마운트 전" 같은 특정 시점에 발동하는 "콜백"이나 "생명주기 이벤트"로 생각하고 싶어질 수 있어요. 하지만 이런 사고방식은 금방 복잡해지니까 피하는 게 좋아요.

대신, 항상 한 번의 시작/중지 사이클에만 집중하세요. 컴포넌트가 마운트 중인지, 업데이트 중인지, 언마운트 중인지는 중요하지 않아요. 여러분이 해야 할 일은 동기화를 어떻게 시작하고 어떻게 중지할지를 설명하는 것뿐이에요. 이걸 잘 해놓으면, Effect는 필요한 만큼 여러 번 시작되고 중지되어도 문제없이 동작할 거예요.

이건 JSX를 생성하는 렌더링 로직을 작성할 때, 컴포넌트가 마운트 중인지 업데이트 중인지 생각하지 않는 것과 비슷해요. 화면에 무엇이 있어야 하는지를 설명하면, React가 나머지를 알아서 처리하죠.

React가 Effect의 재동기화 가능 여부를 확인하는 방법

여기 직접 만져볼 수 있는 실시간 예제가 있어요. "Open chat"을 눌러서 ChatRoom 컴포넌트를 마운트해보세요:

// App.js
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');
  const [show, setShow] = useState(false);
  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>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

컴포넌트가 처음 마운트될 때 세 개의 로그가 보이는 걸 주목해보세요:

  1. ✅ Connecting to "general" room at https://localhost:1234... (개발 모드 전용)
  2. ❌ Disconnected from "general" room at https://localhost:1234. (개발 모드 전용)
  3. ✅ Connecting to "general" room at https://localhost:1234...

처음 두 개의 로그는 개발 모드에서만 나와요. 개발 모드에서 React는 항상 각 컴포넌트를 한 번 더 리마운트해요.

React는 개발 모드에서 Effect를 즉시 강제로 재동기화시켜서, Effect가 재동기화될 수 있는지 검증해요. 이건 문 잠금이 작동하는지 확인하기 위해 문을 한 번 더 열었다 닫는 것과 비슷해요. React는 개발 모드에서 Effect를 한 번 더 시작하고 중지해서 클린업을 잘 구현했는지 확인해요.

실제로 Effect가 재동기화되는 주된 이유는 Effect가 사용하는 데이터가 변경되었을 때예요. 위의 샌드박스에서 선택된 채팅방을 변경해보세요. roomId가 변경되면 Effect가 재동기화되는 게 보일 거예요.

하지만 재동기화가 필요한 더 특이한 경우도 있어요. 예를 들어, 채팅이 열려 있는 상태에서 위의 샌드박스에서 serverUrl을 수정해보세요. 코드 수정에 반응해서 Effect가 재동기화되는 걸 볼 수 있어요. 앞으로 React는 재동기화에 의존하는 더 많은 기능을 추가할 수도 있어요.

React가 Effect를 재동기화해야 하는지 어떻게 아는지

roomId가 변경된 후에 React가 Effect를 재동기화해야 한다는 걸 어떻게 알았는지 궁금할 수 있어요. 그건 여러분이 React에게 코드가 roomId에 의존한다고 의존성 목록에 포함시켜서 알려줬기 때문이에요:

function ChatRoom({ roomId }) { // The roomId prop may change over time
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // This Effect reads roomId 
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // So you tell React that this Effect "depends on" roomId
  // ...

이게 어떻게 작동하는지 설명해볼게요:

  1. roomId가 prop이라는 걸 알고 있었어요. 즉, 시간이 지나면서 변할 수 있다는 뜻이죠.
  2. Effect가 roomId를 읽는다는 걸 알고 있었어요 (그래서 로직이 나중에 변할 수 있는 값에 의존해요).
  3. 그래서 Effect의 의존성으로 지정한 거예요 (roomId가 변할 때 재동기화되도록).

컴포넌트가 리렌더링될 때마다, React는 여러분이 전달한 의존성 배열을 살펴봐요. 배열의 값 중 하나라도 이전 렌더링에서 같은 위치에 전달했던 값과 다르면, React는 Effect를 재동기화해요.

예를 들어, 초기 렌더링에서 ["general"]을 전달했고, 다음 렌더링에서 ["travel"]을 전달했다면, React는 "general""travel"을 비교해요. 이들은 (Object.is로 비교했을 때) 다른 값이므로, React는 Effect를 재동기화해요. 반면에, 컴포넌트가 리렌더링되더라도 roomId가 변하지 않았다면, Effect는 같은 방에 연결된 상태를 유지해요.

각 Effect는 별개의 동기화 프로세스를 나타내요

이미 작성한 Effect와 같은 시점에 실행되어야 한다는 이유만으로 관련 없는 로직을 Effect에 추가하지 마세요. 예를 들어, 사용자가 방을 방문할 때 분석 이벤트를 보내고 싶다고 해볼게요. 이미 roomId에 의존하는 Effect가 있으니까, 거기에 분석 호출을 추가하고 싶을 수 있어요:

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

하지만 나중에 연결을 다시 설정해야 하는 다른 의존성을 이 Effect에 추가한다고 상상해보세요. 이 Effect가 재동기화되면, 의도하지 않았던 같은 방에 대해 logVisit(roomId)도 호출하게 돼요. 방문을 기록하는 건 연결하는 것과는 별개의 프로세스예요. 두 개의 별도 Effect로 작성하세요:

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    // ...
  }, [roomId]);
  // ...
}

코드에서 각 Effect는 별개의 독립적인 동기화 프로세스를 나타내야 해요.

위 예제에서, 한 Effect를 삭제해도 다른 Effect의 로직이 깨지지 않아요. 이건 두 Effect가 서로 다른 것을 동기화하고 있다는 좋은 표시이고, 그래서 분리하는 게 맞아요. 반면에, 하나의 응집된 로직을 별개의 Effect들로 쪼개면 코드가 "깔끔해" 보일 수는 있지만 유지보수하기가 더 어려워져요. 그래서 코드가 깔끔해 보이는지가 아니라, 프로세스가 같은지 별개인지를 기준으로 생각해야 해요.

Effect는 리액티브 값에 "반응"해요

Effect가 두 개의 변수(serverUrlroomId)를 읽지만, 의존성으로는 roomId만 지정했어요:

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

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

serverUrl은 의존성이 될 필요가 없을까요?

serverUrl은 리렌더링으로 인해 절대 변하지 않기 때문이에요. 컴포넌트가 몇 번을 리렌더링하든, 왜 리렌더링하든 항상 같아요. serverUrl이 절대 변하지 않으니까, 의존성으로 지정하는 게 의미가 없겠죠. 결국 의존성은 시간이 지나면서 변할 때만 뭔가를 하니까요!

반면에, roomId는 리렌더링 시 달라질 수 있어요. Props, state, 그리고 컴포넌트 안에서 선언된 다른 값들은 렌더링 중에 계산되고 React 데이터 흐름에 참여하기 때문에 리액티브해요.

만약 serverUrl이 state 변수였다면, 리액티브할 거예요. 리액티브 값은 반드시 의존성에 포함되어야 해요:

function ChatRoom({ roomId }) { // Props change over time
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State may change over time

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads props and state
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So you tell React that this Effect "depends on" on props and state
  // ...
}

serverUrl을 의존성으로 포함시키면, 그 값이 변경된 후에 Effect가 재동기화되는 걸 보장할 수 있어요.

이 샌드박스에서 선택된 채팅방을 변경하거나 서버 URL을 수정해보세요:

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

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

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

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <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} />
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

roomIdserverUrl 같은 리액티브 값을 변경할 때마다, Effect가 채팅 서버에 다시 연결돼요.

빈 의존성 배열의 의미

serverUrlroomId 둘 다 컴포넌트 바깥으로 옮기면 어떻게 될까요?

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

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

이제 Effect의 코드가 어떤 리액티브 값도 사용하지 않으니까, 의존성을 비워둘 수 있어요 ([]).

컴포넌트의 관점에서 생각하면, 빈 [] 의존성 배열은 이 Effect가 컴포넌트가 마운트될 때만 채팅방에 연결하고, 컴포넌트가 언마운트될 때만 연결을 해제한다는 뜻이에요. (React가 개발 모드에서 로직을 스트레스 테스트하기 위해 한 번 더 재동기화한다는 걸 기억하세요.)

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

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

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

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom />}
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

하지만 Effect의 관점에서 생각하면, 마운트나 언마운트에 대해 전혀 생각할 필요가 없어요. 중요한 건 Effect가 동기화를 시작하고 중지하기 위해 무엇을 하는지 지정했다는 거예요. 현재는 리액티브 의존성이 없어요. 하지만 나중에 사용자가 시간이 지나면서 roomIdserverUrl을 변경할 수 있게 하고 싶다면 (그러면 이것들이 리액티브해지겠죠), Effect의 코드는 바꿀 필요 없어요. 의존성에 추가하기만 하면 돼요.

컴포넌트 본문에서 선언된 모든 변수는 리액티브해요

Props와 state만 리액티브 값인 건 아니에요. 이것들로부터 계산하는 값들도 리액티브해요. props나 state가 변하면 컴포넌트가 리렌더링되고, 그것들로부터 계산된 값들도 변할 거예요. 그래서 Effect가 사용하는 컴포넌트 본문의 모든 변수는 Effect 의존성 목록에 있어야 해요.

사용자가 드롭다운에서 채팅 서버를 고를 수 있고, 설정에서 기본 서버를 설정할 수도 있다고 해볼게요. 설정 state를 이미 context에 넣어서 그 context로부터 settings를 읽는다고 가정해요. 이제 props에서 선택된 서버와 기본 서버를 기반으로 serverUrl을 계산해요:

function ChatRoom({ roomId, selectedServerUrl }) { // roomId is reactive
  const settings = useContext(SettingsContext); // settings is reactive
  const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes!
  // ...
}

이 예제에서 serverUrl은 prop도 아니고 state 변수도 아니에요. 렌더링 중에 계산하는 일반 변수예요. 하지만 렌더링 중에 계산되기 때문에, 리렌더링으로 인해 변할 수 있어요. 그래서 리액티브해요.

컴포넌트 안의 모든 값 (props, state, 컴포넌트 본문의 변수들 포함)은 리액티브해요. 모든 리액티브 값은 리렌더링 시 변할 수 있으므로, Effect의 의존성으로 포함시켜야 해요.

다시 말해서, Effect는 컴포넌트 본문의 모든 값에 "반응"해요.

깊게 알아보기: 전역 변수나 가변 값도 의존성이 될 수 있나요?

가변 값(전역 변수 포함)은 리액티브하지 않아요.

location.pathname 같은 가변 값은 의존성이 될 수 없어요. 이건 가변이라서 React 렌더링 데이터 흐름 바깥에서 언제든 변할 수 있어요. 이걸 변경해도 컴포넌트의 리렌더링을 트리거하지 않아요. 따라서 의존성에 지정하더라도, 이 값이 변할 때 React는 Effect를 재동기화해야 한다는 걸 알 수 없어요. 이것은 또한 렌더링 중에 가변 데이터를 읽는 것 (의존성을 계산하는 시점이 바로 그때거든요)이 렌더링의 순수성을 깨뜨리기 때문에 React의 규칙에도 위반돼요. 대신, useSyncExternalStore를 사용해서 외부 가변 값을 읽고 구독해야 해요.

ref.current 같은 가변 값이나 거기서 읽는 것들도 의존성이 될 수 없어요. useRef가 반환하는 ref 객체 자체는 의존성이 될 수 있지만, 그것의 current 프로퍼티는 의도적으로 가변이에요. 이건 리렌더링을 트리거하지 않으면서 무언가를 추적할 수 있게 해줘요. 하지만 변경해도 리렌더링을 트리거하지 않으니까, 리액티브 값이 아니고, React는 이 값이 변할 때 Effect를 다시 실행해야 한다는 걸 알 수 없어요.

이 페이지 아래에서 배우겠지만, 린터가 이런 문제들을 자동으로 체크해줄 거예요.

React는 모든 리액티브 값을 의존성으로 지정했는지 검증해요

린터가 React용으로 설정되어 있다면, Effect 코드에서 사용하는 모든 리액티브 값이 의존성으로 선언되었는지 검사해요. 예를 들어, roomIdserverUrl 둘 다 리액티브하기 때문에 이건 린트 에러예요:

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

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- Something's wrong here!

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <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} />
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

이건 React 에러처럼 보일 수 있지만, 실제로 React는 여러분의 코드에 있는 버그를 지적하고 있는 거예요. roomIdserverUrl 모두 시간이 지나면서 변할 수 있는데, 변경될 때 Effect를 재동기화하는 걸 잊고 있어요. 사용자가 UI에서 다른 값을 선택한 후에도 초기 roomIdserverUrl에 연결된 상태로 남아있게 될 거예요.

버그를 수정하려면, 린터의 제안에 따라 roomIdserverUrl을 Effect의 의존성으로 지정하세요:

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]); // ✅ All dependencies declared
  // ...
}

위의 샌드박스에서 이 수정을 시도해보세요. 린터 에러가 사라지고, 채팅이 필요할 때 다시 연결되는지 확인해보세요.

참고

어떤 경우에는 React가 컴포넌트 안에서 선언되었더라도 값이 절대 변하지 않는다는 걸 알아요. 예를 들어, useState에서 반환된 set 함수useRef가 반환한 ref 객체는 안정적이에요 — 리렌더링 시 변하지 않는다는 게 보장돼요. 안정적인 값은 리액티브하지 않으므로 목록에서 생략할 수 있어요. 포함시켜도 괜찮아요: 변하지 않으니까 상관없어요.

재동기화를 원하지 않을 때 어떻게 하나요

이전 예제에서 roomIdserverUrl을 의존성으로 나열해서 린트 에러를 수정했어요.

하지만 대신에 이 값들이 리액티브 값이 아니라는 걸 린터에게 "증명"할 수도 있어요. 즉, 리렌더링의 결과로 변할 수 없다는 걸요. 예를 들어, serverUrlroomId가 렌더링에 의존하지 않고 항상 같은 값을 가진다면, 컴포넌트 바깥으로 옮길 수 있어요. 이제 이것들은 의존성이 될 필요가 없어요:

const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
const roomId = 'general'; // roomId is not reactive

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

이것들을 Effect 안으로 옮길 수도 있어요. 렌더링 중에 계산되는 게 아니니까 리액티브하지 않아요:

function ChatRoom() {
  useEffect(() => {
    const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
    const roomId = 'general'; // roomId is not reactive
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

Effect는 리액티브한 코드 블록이에요. 안에서 읽는 값이 변하면 재동기화돼요. 상호작용마다 한 번만 실행되는 이벤트 핸들러와 달리, Effect는 동기화가 필요할 때마다 실행돼요.

의존성을 "선택"할 수 없어요. 의존성에는 Effect에서 읽는 모든 리액티브 값을 포함해야 해요. 린터가 이걸 강제해요. 때로는 무한 루프가 생기거나 Effect가 너무 자주 재동기화되는 문제가 발생할 수 있어요. 이런 문제를 린터를 억제해서 고치지 마세요! 대신 이렇게 해보세요:

⚠️ 주의

린터는 여러분의 친구지만, 능력에는 한계가 있어요. 린터는 의존성이 잘못되었을 때만 알 수 있어요. 각 경우를 해결하는 최선의 방법은 알지 못해요. 린터가 의존성을 제안하지만 추가하면 루프가 발생한다면, 그게 린터를 무시해야 한다는 뜻이 아니에요. Effect 안 (또는 밖)의 코드를 변경해서 그 값이 리액티브하지 않게 만들고 의존성이 될 필요가 없게 해야 해요.

기존 코드베이스가 있다면, 이런 식으로 린터를 억제하는 Effect가 있을 수 있어요:

useEffect(() => {
  // ...
  // 🔴 Avoid suppressing the linter like this:
  // eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

다음 페이지들에서 규칙을 깨지 않으면서 이 코드를 수정하는 방법을 배울 거예요. 고치는 건 항상 가치가 있어요!

요약

  • 컴포넌트는 마운트, 업데이트, 언마운트할 수 있어요.
  • 각 Effect는 주변 컴포넌트와 별개의 생명주기를 가져요.
  • 각 Effect는 시작중지가 가능한 별개의 동기화 프로세스를 설명해요.
  • Effect를 작성하고 읽을 때, 컴포넌트의 관점 (마운트, 업데이트, 언마운트 방식)이 아니라 각 개별 Effect의 관점 (동기화를 시작하고 중지하는 방법)에서 생각하세요.
  • 컴포넌트 본문 안에서 선언된 값은 "리액티브"해요.
  • 리액티브 값은 시간이 지나면서 변할 수 있으므로 Effect를 재동기화해야 해요.
  • 린터는 Effect 안에서 사용되는 모든 리액티브 값이 의존성으로 지정되었는지 검증해요.
  • 린터가 표시하는 모든 에러는 정당해요. 규칙을 깨지 않으면서 코드를 수정하는 방법은 항상 있어요.

도전 과제

도전 1: 모든 키 입력에서 재연결되는 문제 수정하기

이 예제에서, ChatRoom 컴포넌트는 컴포넌트가 마운트될 때 채팅방에 연결하고, 언마운트될 때 연결 해제하고, 다른 채팅방을 선택하면 다시 연결해요. 이 동작은 맞으니까 계속 작동하게 유지해야 해요.

하지만 문제가 있어요. 아래쪽의 메시지 박스 입력란에 타이핑할 때마다 ChatRoom이 채팅에 다시 연결돼요. (콘솔을 지우고 입력란에 타이핑해보면 알 수 있어요.) 이 문제를 수정해서 이런 일이 일어나지 않게 하세요.

힌트: 이 Effect에 의존성 배열을 추가해야 할 수도 있어요. 어떤 의존성이 들어가야 할까요?

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

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

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

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

  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} />
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

해답

이 Effect는 의존성 배열이 아예 없었기 때문에, 매 리렌더링마다 재동기화되고 있었어요. 먼저 의존성 배열을 추가하세요. 그다음, Effect가 사용하는 모든 리액티브 값이 배열에 지정되어 있는지 확인하세요. 예를 들어, roomId는 리액티브 (prop이니까)하므로 배열에 포함되어야 해요. 이렇게 하면 사용자가 다른 방을 선택할 때 채팅이 다시 연결돼요. 반면에 serverUrl은 컴포넌트 바깥에서 정의되어 있어요. 그래서 배열에 넣을 필요가 없어요.

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

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

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

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

  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} />
    </>
  );
}
// src/chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

도전 2: 동기화 켜고 끄기

이 예제에서, Effect는 window의 pointermove 이벤트를 구독해서 분홍색 점을 화면에서 움직여요. 미리보기 영역 위에 마우스를 올려보세요 (모바일이라면 화면을 터치하세요). 분홍색 점이 여러분의 움직임을 따라가는 걸 볼 수 있어요.

체크박스도 있어요. 체크박스를 토글하면 canMove state 변수가 변경되는데, 이 state 변수는 코드 어디에서도 사용되지 않고 있어요. 여러분의 과제는 canMovefalse일 때 (체크박스가 해제됐을 때) 점이 움직이지 않게 코드를 변경하는 거예요. 체크박스를 다시 켜면 (canMovetrue로 설정하면), 점이 다시 움직임을 따라가야 해요. 즉, 점이 움직일 수 있는지 여부가 체크박스의 체크 상태와 동기화되어야 해요.

힌트: Effect를 조건부로 선언할 수는 없어요. 하지만 Effect 안의 코드는 조건을 사용할 수 있어요!

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

해답

한 가지 해결 방법은 setPosition 호출을 if (canMove) { ... } 조건으로 감싸는 거예요:

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      if (canMove) {
        setPosition({ x: e.clientX, y: e.clientY });
      }
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, [canMove]);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

또는 이벤트 구독 로직을 if (canMove) { ... } 조건으로 감쌀 수도 있어요:

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    if (canMove) {
      window.addEventListener('pointermove', handleMove);
      return () => window.removeEventListener('pointermove', handleMove);
    }
  }, [canMove]);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

두 경우 모두, canMove는 Effect 안에서 읽는 리액티브 변수예요. 그래서 Effect 의존성 목록에 지정해야 해요. 이렇게 하면 값이 변경될 때마다 Effect가 재동기화돼요.


도전 3: 오래된 값 버그 조사하기

이 예제에서, 분홍색 점은 체크박스가 켜져 있으면 움직이고, 체크박스가 꺼져 있으면 멈춰야 해요. 이에 대한 로직은 이미 구현되어 있어요: handleMove 이벤트 핸들러가 canMove state 변수를 확인하고 있거든요.

하지만 어째서인지 handleMove 안의 canMove state 변수가 "오래된" 것처럼 보여요: 체크박스를 해제한 후에도 항상 true예요. 어떻게 이런 일이 가능할까요? 코드에서 실수를 찾고 수정하세요.

힌트: 린터 규칙이 억제되어 있는 걸 보면, 억제를 제거하세요! 보통 거기에 실수가 있어요.

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

해답

원래 코드의 문제는 의존성 린터를 억제한 거였어요. 억제를 제거하면, 이 Effect가 handleMove 함수에 의존한다는 린트 에러가 보일 거예요. 이건 맞는 말이에요: handleMove는 컴포넌트 본문 안에서 선언되어 있으니까 리액티브 값이에요. 모든 리액티브 값은 의존성으로 지정해야 하고, 안 그러면 시간이 지나면서 오래된 값이 될 수 있어요!

원래 코드의 작성자가 Effect가 어떤 리액티브 값에도 의존하지 않는다고 ([]) React에게 "거짓말"한 거예요. 그래서 React가 canMove가 변경된 후에 (그리고 handleMove도 같이) Effect를 재동기화하지 않았어요. React가 Effect를 재동기화하지 않았기 때문에, 리스너로 등록된 handleMove는 초기 렌더링에서 생성된 handleMove 함수예요. 초기 렌더링에서 canMovetrue였기 때문에, 초기 렌더링의 handleMove는 영원히 그 값을 보게 돼요.

린터를 절대 억제하지 않으면, 오래된 값 문제를 절대 보지 않을 거예요. 이 버그를 해결하는 방법은 여러 가지가 있지만, 항상 린터 억제를 제거하는 것부터 시작해야 해요. 그다음 린트 에러를 수정하도록 코드를 변경하세요.

Effect 의존성을 [handleMove]로 변경할 수 있지만, 매 렌더링마다 새로 정의되는 함수이므로 의존성 배열을 아예 제거하는 게 나을 수도 있어요. 그러면 Effect가 매 리렌더링 후에 재동기화될 거예요:

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  });

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

이 해결법은 동작하지만 이상적이진 않아요. Effect 안에 console.log('Resubscribing')을 넣어보면, 매 리렌더링마다 다시 구독하는 걸 볼 수 있어요. 재구독은 빠르지만, 그래도 너무 자주 하는 건 피하는 게 좋겠죠.

더 나은 수정 방법은 handleMove 함수를 Effect 안으로 옮기는 거예요. 그러면 handleMove가 리액티브 값이 아니게 되고, Effect가 함수에 의존하지 않게 돼요. 대신 코드가 Effect 안에서 읽는 canMove에 의존하게 되겠죠. 여러분이 원했던 동작과 일치해요: Effect가 canMove 값과 동기화된 상태를 유지할 테니까요:

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      if (canMove) {
        setPosition({ x: e.clientX, y: e.clientY });
      }
    }

    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, [canMove]);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)} 
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

Effect 본문 안에 console.log('Resubscribing')을 추가해보면, 이제 체크박스를 토글하거나 (canMove가 변경) 코드를 수정할 때만 다시 구독하는 걸 볼 수 있어요. 항상 재구독하던 이전 접근 방식보다 나아졌죠.

이런 유형의 문제에 대한 더 일반적인 접근 방법은 이벤트와 Effect 분리하기에서 배울 수 있어요.


도전 4: 연결 스위치 수정하기

이 예제에서, chat.js의 채팅 서비스는 두 가지 다른 API를 노출해요: createEncryptedConnectioncreateUnencryptedConnection. 루트 App 컴포넌트는 사용자가 암호화를 사용할지 말지 선택할 수 있게 하고, 해당하는 API 메서드를 자식 ChatRoom 컴포넌트에 createConnection prop으로 전달해요.

처음에 콘솔 로그를 보면 연결이 암호화되지 않았다고 나와요. 체크박스를 켜보세요: 아무 일도 안 일어나요. 하지만 그 후에 선택된 방을 변경하면, 채팅이 다시 연결되면서 암호화가 활성화돼요 (콘솔 메시지에서 볼 수 있어요). 이건 버그예요. 체크박스를 토글하는 것 채팅을 다시 연결하게 수정하세요.

힌트: 린터를 억제하는 건 항상 의심스러워요. 이게 버그일 수 있지 않을까요?

// src/App.js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = useState(false);
  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>
      <label>
        <input
          type="checkbox"
          checked={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        createConnection={isEncrypted ?
          createEncryptedConnection :
          createUnencryptedConnection
        }
      />
    </>
  );
}
// src/ChatRoom.js
import { useState, useEffect } from 'react';

export default function ChatRoom({ roomId, createConnection }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
// src/chat.js
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

해답

린터 억제를 제거하면, 린트 에러가 보일 거예요. 문제는 createConnection이 prop이라서 리액티브 값이라는 거예요. 시간이 지나면서 변할 수 있어요! (실제로, 사용자가 체크박스를 토글하면 부모 컴포넌트가 다른 값의 createConnection prop을 전달하니까요.) 그래서 이건 의존성이어야 해요. 목록에 포함시켜서 버그를 수정하세요:

// src/App.js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = useState(false);
  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>
      <label>
        <input
          type="checkbox"
          checked={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        createConnection={isEncrypted ?
          createEncryptedConnection :
          createUnencryptedConnection
        }
      />
    </>
  );
}
// src/ChatRoom.js
import { useState, useEffect } from 'react';

export default function ChatRoom({ roomId, createConnection }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, createConnection]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
// src/chat.js
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

createConnection이 의존성인 건 맞아요. 하지만 이 코드는 약간 취약해요. 누군가가 App 컴포넌트를 수정해서 이 prop의 값으로 인라인 함수를 전달할 수 있거든요. 그런 경우에는 App 컴포넌트가 리렌더링될 때마다 값이 달라져서 Effect가 너무 자주 재동기화될 수 있어요. 이걸 피하려면, 대신 isEncrypted를 내려보낼 수 있어요:

// src/App.js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = useState(false);
  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>
      <label>
        <input
          type="checkbox"
          checked={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        isEncrypted={isEncrypted}
      />
    </>
  );
}
// src/ChatRoom.js
import { useState, useEffect } from 'react';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function ChatRoom({ roomId, isEncrypted }) {
  useEffect(() => {
    const createConnection = isEncrypted ?
      createEncryptedConnection :
      createUnencryptedConnection;
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, isEncrypted]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
// src/chat.js
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

이 버전에서는 App 컴포넌트가 함수 대신 boolean prop을 전달해요. Effect 안에서 어떤 함수를 사용할지 결정하죠. createEncryptedConnectioncreateUnencryptedConnection 둘 다 컴포넌트 바깥에서 선언되어 있으니까 리액티브하지 않고, 의존성이 될 필요도 없어요. 이에 대해 Effect 의존성 제거하기에서 더 배울 거예요.


도전 5: 연쇄 선택 박스 채우기

이 예제에는 두 개의 선택 박스가 있어요. 하나는 행성을 선택할 수 있게 해주고, 다른 하나는 그 행성의 장소를 선택할 수 있게 해줘요. 두 번째 선택 박스는 아직 동작하지 않아요. 여러분의 과제는 선택한 행성의 장소들을 표시하게 만드는 거예요.

첫 번째 선택 박스가 어떻게 동작하는지 살펴보세요. "/planets" API 호출의 결과로 planetList state를 채워요. 현재 선택된 행성의 ID는 planetId state 변수에 보관돼요. placeList state 변수가 "/planets/" + planetId + "/places" API 호출의 결과로 채워지도록 코드를 추가할 곳을 찾아야 해요.

이걸 제대로 구현하면, 행성을 선택할 때 장소 목록이 채워져야 해요. 행성을 변경하면 장소 목록도 변해야 하고요.

힌트: 두 개의 독립적인 동기화 프로세스가 있다면, 두 개의 별도 Effect를 작성해야 해요.

// src/App.js
import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export default function Page() {
  const [planetList, setPlanetList] = useState([])
  const [planetId, setPlanetId] = useState('');

  const [placeList, setPlaceList] = useState([]);
  const [placeId, setPlaceId] = useState('');

  useEffect(() => {
    let ignore = false;
    fetchData('/planets').then(result => {
      if (!ignore) {
        console.log('Fetched a list of planets.');
        setPlanetList(result);
        setPlanetId(result[0].id); // Select the first planet
      }
    });
    return () => {
      ignore = true;
    }
  }, []);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '???'} on {planetId || '???'} </p>
    </>
  );
}
label { display: block; margin-bottom: 10px; }

해답

두 개의 독립적인 동기화 프로세스가 있어요:

  • 첫 번째 선택 박스는 원격 행성 목록에 동기화돼요.
  • 두 번째 선택 박스는 현재 planetId에 대한 원격 장소 목록에 동기화돼요.

그래서 이것들을 두 개의 별도 Effect로 설명하는 게 맞아요. 다음은 이걸 어떻게 할 수 있는지에 대한 예제예요:

// src/App.js
import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export default function Page() {
  const [planetList, setPlanetList] = useState([])
  const [planetId, setPlanetId] = useState('');

  const [placeList, setPlaceList] = useState([]);
  const [placeId, setPlaceId] = useState('');

  useEffect(() => {
    let ignore = false;
    fetchData('/planets').then(result => {
      if (!ignore) {
        console.log('Fetched a list of planets.');
        setPlanetList(result);
        setPlanetId(result[0].id); // Select the first planet
      }
    });
    return () => {
      ignore = true;
    }
  }, []);

  useEffect(() => {
    if (planetId === '') {
      // Nothing is selected in the first box yet
      return;
    }

    let ignore = false;
    fetchData('/planets/' + planetId + '/places').then(result => {
      if (!ignore) {
        console.log('Fetched a list of places on "' + planetId + '".');
        setPlaceList(result);
        setPlaceId(result[0].id); // Select the first place
      }
    });
    return () => {
      ignore = true;
    }
  }, [planetId]);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '???'} on {planetId || '???'} </p>
    </>
  );
}
label { display: block; margin-bottom: 10px; }

이 코드는 약간 반복적이에요. 하지만 그렇다고 하나의 Effect로 합치는 건 좋은 이유가 아니에요! 그렇게 하면 두 Effect의 의존성을 하나의 목록으로 합쳐야 하고, 그러면 행성을 변경할 때 모든 행성 목록까지 다시 가져오게 될 거예요. Effect는 코드 재사용을 위한 도구가 아니에요.

대신, 반복을 줄이려면 아래의 useSelectOptions 같은 커스텀 Hook으로 로직을 추출할 수 있어요:

// src/App.js
import { useState } from 'react';
import { useSelectOptions } from './useSelectOptions.js';

export default function Page() {
  const [
    planetList,
    planetId,
    setPlanetId
  ] = useSelectOptions('/planets');

  const [
    placeList,
    placeId,
    setPlaceId
  ] = useSelectOptions(planetId ? `/planets/${planetId}/places` : null);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList?.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList?.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '...'} on {planetId || '...'} </p>
    </>
  );
}
// src/useSelectOptions.js
import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export function useSelectOptions(url) {
  const [list, setList] = useState(null);
  const [selectedId, setSelectedId] = useState('');
  useEffect(() => {
    if (url === null) {
      return;
    }

    let ignore = false;
    fetchData(url).then(result => {
      if (!ignore) {
        setList(result);
        setSelectedId(result[0].id);
      }
    });
    return () => {
      ignore = true;
    }
  }, [url]);
  return [list, selectedId, setSelectedId];
}
label { display: block; margin-bottom: 10px; }

샌드박스에서 useSelectOptions.js 탭을 확인해서 어떻게 작동하는지 봐보세요. 이상적으로는 애플리케이션의 대부분의 Effect가 결국에는 커스텀 Hook으로 대체되어야 해요. 여러분이 직접 작성하든 커뮤니티에서 가져오든요. 커스텀 Hook은 동기화 로직을 숨겨서, 호출하는 컴포넌트가 Effect에 대해 알 필요가 없게 해요. 앱에서 계속 작업하다 보면 선택할 수 있는 Hook들의 팔레트가 생길 거고, 결국에는 컴포넌트에서 직접 Effect를 작성할 일이 그리 많지 않게 될 거예요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글