이펙트는 컴포넌트와 다른 생명주기를 가져요. 컴포넌트는 마운트, 업데이트 또는 언마운트 돼요. 이펙트는 무언가와 동기화를 시작하는 작업과 이후에 동기화를 중지하는 작업만을 수행할 수 있어요. 이 주기는 이펙트가 시간이 지나면서 변하는 props나 상태에 기반하고 있다면 여러번 발생해요. 리액트는 이펙트의 의존성을 올바르게 지정했는지를 확인하는 린터 규칙을 제공해요. 이를 통해 이펙트는 최신 props 및 상태와 동기화돼요.
이 페이지에서는
- 이펙트의 생명주기가 컴포넌트의 생명주기와 어떻게 다른지
- 각각의 이펙트를 어떻게 분리해서 생각하는지
- 이펙트가 다시 동기화되어야 할 때는 언제고 왜인지
- 이펙트의 의존성을 어떻게 결정하는지
- 값이 반응적이라는 것은 무슨 의미인지
- 빈 의존성 배열이 무엇을 의미하는지
- 리액트가 린터를 사용하여 의존성이 알맞게 지정되었다는 것을 어떻게 검증하는지
- 린터와 생각이 다를 땐 무엇을 해야하는지
를 알아볼 거예요.
The lifecycle of an Effect | 이펙트의 생명주기
모든 리액트 컴포넌트는 동일한 생명주기를 가져요.
- 컴포넌트가 화면에 추가될 때 _마운트_돼요.
- 상호작용에 따라 컴포넌트가 새로운 props나 상태를 받을 때 _업데이트_돼요.
- 컴포넌트가 화면에서 사라질 때 _언마운트_돼요.
컴포넌트의 생명주기를 이렇게 생각하는 것은 옳은 방법이지만, 이펙트의 생명주기는 이렇게 생각해선 안돼요. 대신, 각 이펙트는 컴포넌트의 생명주기와는 독립적으로 생각하세요. 이펙트는 현재 props 및 상태와 외부 시스템을 동기화하는 방법을 표현해요. 코드가 바뀌면 동기화 작업도 꽤나 자주 발생해요.
이 부분을 설명하기 위해서 컴포넌트를 채팅 서버에 연결하는 이 이펙트를 보세요.
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
이펙트의 바디는 동기화를 시작하는 방법을 지정해줘요.
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
// ...
이펙트에서 반환된 클린업 함수는 동기화를 중단하는 방법을 지정해요.
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
// ...
직관적으로, 리액트는 컴포넌트가 마운트 될 때 동기화를 시작하고 컴포넌트가 언마운트 될 때 동기화를 중단할 것이라고 생각할 거예요. 그러나 이게 전부는 아니에요! 때때로, 컴포넌트가 마운트된 상태로 유지될 때 동기화를 시작하고 중단하는 작업은 여러번 반복할 필요가 있어요.
왜 이 과정이 필요하고 언제 발생하고 어떻게 이 행동을 컨트롤하는지를 살펴볼게요.
노트
어떤 이펙트는 클린업 함수를 전혀 반환하지 않아요. 대부분의 경우 하나를 반환하는 것이 좋지만 그렇지 않다면 리액트는 빈 클린업 함수를 반환한 것처럼 동작해요.
Why synchronization may need to happen more than once | 동기화가 한 번 이상 일어나야하는 이유
ChatRoom
컴포넌트는 사용자가 드롭다운에서 선택한 roomId
prop을 받아요. 맨 처음에 사용자가roomId
로 "general"
방을 선택했다고 할게요. 앱은 "general"
채팅방을 보여줘요.
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId /* "general" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}
UI가 나타난 후에 리액트는 동기화를 시작하는 이펙트를 실행해요. 리액트는 "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]);
// ...
여태까지는 그럭저럭 잘 됐어요.
그 다음, 사용자는 드롭다운에서 다른 채팅방을 선택해요. (예시: "traval"
) 먼저 리액트는 UI를 업데이트 해요.
function ChatRoom({ roomId /* "travel" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}
다음에는 무슨 일이 생길지 생각해보세요. 사용자는 "travel"
이 선택된 채팅방이라는 것을 UI에서 볼 수 있어요. 그러나 마지막에 실행한 이펙트는 "general"
방과 여전히 연결되어 있어요. roomId
prop이 변경되었기 때문에 이펙트가 이전에 했던 것(general"
방과의 연결)은 이제 더 이상 UI와 맞지 않아요.
이 지점에서 리액트가 두 가지를 하기를 바랄 거예요.
- 이전의
roomId
와의 동기화 종료 ("general"
방과 연결 중단) - 새로운
roomdId
로 동기화 시작 ("travel"
방과 연결)
운 좋게도 리액트는 이 두 가지를 어떻게 해야하는지를 이미 알고 있어요! 리액트의 바디응 동기화를 시작하는 방법을, 클린업 함수는 동기화를 중단하는 방법을 지정해줘요. 리액트가 해야하는 것은 이제 알맞은 순서와 알맞은 props와 상태로 이들을 호출하는 거예요. 정확하게 무슨 일이 발생하는지 확인해볼게요.
How React re-synchronizes your Effect | 리액트가 이펙트를 재동기화 시키는 방법
ChatRoom
컴포넌트가 roomId
prop을 새로운 값으로 받았다는 것을 상기해보세요. 원래는 "general"
이었지만 이제는 "travel"
이에요. 리액트는 다른 방과 다시 연결하기 위하여 이펙트를 다시 동기화해야해요.
동기화를 멈추려면 리액트는 이펙트가 "general"
방과 연결한 후에 반환한 정리 함수를 호출할 거예요. 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
};
// ...
그리고나서 리액트는 이번 렌더링에서 받은 이펙트를 실행할 거예요. 이번에 roomId
는 "travel"
이기 떄문에 "travel"
채팅방으로 동기화를 시작해요. (클린업 함수가 결국 호출되기 전까지요.)
function ChatRoom({ roomId /* "travel" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room
connection.connect();
// ...
이 덕분에 이제는 UI에서 사용자가 선택한 것과 동일한 방과 연결돼요. 재앙을 피했어요!
컴포넌트가 다른 roomId
로 리렌더링될 때마다 이펙트는 다시 동기화돼요. 예를 들어 사용자가 roomId
를 "travel"
에서 "music"
으로 변경했다고 할게요. 리액트는 다시 이펙트의 클린업 함수("travel"
방과의 연결을 끊기)를 호출하여 이펙트와의 동기화를 중단해요. 그리고나서 새로운 roomId
prop으로 바디를 실행하여 다시 동기화를 시작하세요. ("music"
방으로 연결)
마지막으로 사용자는 다른 화면으로 이동하고 ChatRoom
을 언마운트해요. 이제 어느 곳과도 연결을 유지할 필요가 없어요. 리액트는 마지막으로 이펙트를 동기화하는 것을 멈추고 "music"
채팅방과의 연결을 끊어요.
Thinking from the Effect’s perspective | 이펙트의 관점에서 생각하기
ChatRoom
컴포넌트의 관점에서 지금까지 일어난 모든 일을 요약해볼게요.
"general"
라고 설정된roomId
로ChatRoom
이 마운트됐어요."travel"
라고 설정된roomId
로ChatRoom
이 업데이트되었어요."music"
라고 설정된roomId
로ChatRoom
이 업데이트되었어요.ChatRoom
이 언마운트 되었어요.
컴포넌트의 생명주기에서의 각 시점마다 이펙트는 다른 것들을 해요.
- 이펙트가
"general"
방과 연결돼요. - 이펙트가
"general'
방과 연결을 중단하고"travel"
방과 연결돼요. - 이펙트가
"travel'
방과 연결을 중단하고"music"
방과 연결돼요. - 이펙트가
"music'
방과 연결을 중단돼요.
이제 이펙트의 관점에서 무슨 일이 벌어졌는지 알아볼게요.
useEffect(() => {
// 이펙트는 roomId로 지정된 방과 연결해요
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...연결을 끊을 때 까지요
connection.disconnect();
};
}, [roomId]);
이 코드의 구조는 시간이 겹치지 않는 과정에서 발생한 일들을 파악하도록 해줘요.
- 이펙트는
"general"
방과 연결되었어요. (연결을 끊을 때까지) - 이펙트는
"travel"
방과 연결되었어요. (연결을 끊을 때까지) - 이펙트는
"music"
방과 연결되었어요. (연결을 끊을 때까지)
아까는 컴포넌트의 관점에서 생각했어요. 컴포넌트의 관점에서 볼 때, 이펙트는 "렌더링 후" 또는 "언마운트 전"과 같은 특정한 때에 실행되는 "콜백" 또는 "생명주기 이벤트"라고 생각하세요. 이렇게 생각하면 굉장히 빠르게 복잡해지기 때문에 피하는 게 좋아요.
대신 한 번에 하나의 시작 또는 중단 사이클에 집중하세요. 컴포넌트가 마운트 되었는지, 업데이트 되었는지, 언마운트 되었는지는 중요하지 않아요. 해야할 일은 동기화를 시작하는 방법, 동기화를 중단하는 방법을 설명하는 거예요. 만약 이를 잘 한다면 필요한 만큼 시작하고 중단되는 탄력적인 이펙트가 돼요.
여기서 여러분은 JSX를 생성하는 렌더링 로직을 작성할 때 컴포넌트가 마운트되었는지 업데이트 되었는지를 생각하지 않는 방법을 떠올릴 거예요. 화면에 무엇을 띄워주어야하는지만 설명하면 리액트는 나머지를 찾아낼 거예요.
How React verifies that your Effect can re-synchronize | 이펙트를 다시 동기화할 수 있는지를 리액트가 검증하는 방법
여러분이 다뤄볼 수 있는 예시에요. "Open chat"을 눌러 ChatRoom
컴포넌트를 마운트 하세요.
컴포넌트가 처음으로 마운트 될 때 세개의 로그가 떠요.
✅ Connecting to "general" room at https://localhost:1234...
(개발모드 전용)❌ Disconnected from "general" room at https://localhost:1234.
(개발모드 전용)✅ Connecting to "general" room at https://localhost:1234...
1, 2번 로그는 개발 모드에서만 떠요. 개발 모드에서 리액트는 항상 각 컴포넌트를 한 번 더 마운트해요.
리액트는 개발 모드에서 즉시 강제로 이펙트를 다시 동기화 시킬 수 있는지를 확인해요. 여기서 여러분은 도어락이 잘 작동하는지를 확인하기 위해 문을 한 번 더 여닫는 행동이 생각날 거예요. 리액트는 클린업 함수를 잘 구현했는지 확인하기 위해 개발 모드에서 한 번 더 이펙트를 시작하고 중단해요.
이펙트가 실제로 다시 동기화를 하는 이유는 사용한 데이터가 변경되었을 수도 있기 때문이에요. 위의 샌드박스에서 선택된 채팅창을 변경해보세요. roomId
가 변경되면 이펙트가 다시 동기화된다는 것을 확인할 수 있어요.
그러나 재동기화가 필요한 흔치 않은 상황도 있어요. 예를 들어 위의 샌드박스에서 채팅이 열려있는 동안 serverUrl
을 수정해보세요. 여러분이 코드를 수정하면 이펙트는 재동기화돼요. 나중에 리액트는 재동기화에 의존하는 추가적인 특성들을 추가할 수도 있어요.
How React knows that it needs to re-synchronize the Effect | 이펙트를 다시 동기화 시켜야 하는지를 리액트가 알고 있는 방법
roomId
가 바뀌었을 떄 이펙트가 재동기화가 필요한지를 리액트가 어떻게 알고 있는지 궁금할 거예요. 그 이유는 의존성 목록에 포함된 roomId
에 코드가 의존하고 있다고 여러분이 리액트에게 말해주기 때문이에요.
function ChatRoom({ roomId }) { // roomId prop은 시간이 지나면 변할 수도 있어요
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이 이펙트는 roomId를 읽어요
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]); // 따라서 리액트에게 이 이펙트는 roodId에 "의존한다"고 말해요
// ...
이 코드가 작동하는 방법이 아래와 같아요.
roomId
가 prop이라는 사실을 알고 이는 시간이 지나면 변할 수 있다는 것을 의미해요.- 이펙트가
roomId
를 읽는다는 사실을 알아요. (그래서 로직은 시간이 지나면 달라질 수 있는 값에 의존해요. - 이펙트의 의존성으로 이 변수를 지정한 이유가 바로 이 때문이에요. (이는
roomId
가 변할 때 이펙트가 다시 동기화 되도록 만들어요.)
컴포넌트가 리렌더링 될 때마다 리액트는 여러분이 전달한 의존성 배열을 확인해요. 만약 배열의 값 중 하나라도 이전의 렌더링동안 전달한 곳의 값에서 달라졌다면 리액트는 이펙트를 다시 동기화해요.
예를 들어, 만약 ["general"]
을 초기 렌더링동안 전달했다면, 그리고 그 다음 렌더링 동안 ["travel"]
을 전달했다면 리액트는 "general"
과 "travel"
을 비교해요. 이들은 (Object.js
로 비교했을 때) 서로 다른 값이기 때문에 리액트는 이펙트를 다시 동기화시켜요. 반면, 컴포넌트가 리렌더링 되었지만 roomId
가 변경되지 않았다면 이펙트는 동일한 방과의 연결을 유지할 거예요.
Each Effect represents a separate synchronization process | 각 이펙트는 서로 다른 동기화 과정을 나타낸다
이펙트에 관계없는 로직을 추가하지 마세요. 이 로직은 이미 작성한 이펙트와 동시에 실행되어야하기 때문이에요. 예를 들어, 사용자가 방에 방문하면 통계 이벤트를 전송해야한다고 할게요. 이미 roomId
에 의존하는 이펙트를 가지고 있기 때문에 이곳에서 통계를 추가하고 싶을 거예요.
function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
그러나 나중에 연결을 다시 해줄 또 다른 의존성을 이펙트에 추가한다고 생각해보세요. 만약 이 이펙트가 재동기화 된다면 같은 방에서 logVisit(roomId)
도 다시 호출할 것이고 이는 여러분이 의도했던 작동이 아닐 거예요. 방문을 로깅하는 것은 연결과는 별개의 과정이에요. 이 둘을 각기 다른 이펙트로 작성하세요.
function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
}, [roomId]);
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
// ...
}, [roomId]);
// ...
}
코드 안의 각 이펙트는 서로 다른 독립적인 동기화 과정을 보여줘야해요.
위의 예시에서 하나의 이펙트를 지워도 다른 이펙트의 로직이 망가지면 안돼요. 이는 이 이펙트들이 다른 것들을 동기화하기 때문에 서로 분리하는 것이 합리적이라는 표시에요. 반면 만약 서로 관련이 있는 로직을 각각의 이펙트로 분리한다면 코드는 더 "깔끔해" 보일지도 모르지만 유지보수가 어려운 코드가 돼요. 이 때문에 여러분은 코드가 더 깨끗해 보이는지가 아니라 프로세스가 동일한지 아니면 별개인지를 고민해 보아야해요.
Effects “react” to reactive values | 이펙트는 반응 값에 반응한다
이펙트는 두 변수(serverUrl
, roodId
)를 읽지만 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, 상태 그리고 컴포넌트 내부에서 선언된 다른 변수들은 렌더링 중에 계산되고 리액트 데이터 흐름에 참여하기 때문에 반응적이에요.
만약 serverUrl
이 상태 변수라면 반응적이지 않을 거예요. 반응형 값을 의존성에 무조건 포함되어야해요.
function ChatRoom({ roomId }) { // props는 변해요
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // 상태는 변해요
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이펙트는 props와 상태를 읽어요
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 따라서 리액트에서 이 이펙트는 props와 상태에 의존한다고 알려줘야해요
// ...
}
serverUrl
을 의존성으로 포함하여 이들이 변할 때마다 이펙트가 재동기화하도록 만들게 돼요.
아래의 샌드박스에서 선택된 채팅방을 변경하거나 서버 URL을 수정해보세요.
roomId
나 serverUrl
과 같은 반응적인 값을 바꿀 때마다 이펙트는 채팅 서버와 다시 연결해요.
What an Effect with empty dependencies means | 빈 의존성 배열을 가지는 이펙트가 의미하는 것
만약 serverUrl
과 roomId
가 컴포넌트 외부에서 모두 바뀌면 어떻게 될까요?
const serverUrl = 'https://localhost:1234';
const roomId = 'general';
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ 모든 의존성이 선언되었어요
// ...
}
이제 이 이펙트의 코드는 어떤 반응적인 값도 사용하지 않아요. 그래서 의존성은 빈배열이 될 수 있어요. ([]
)
컴포넌트의 관점에서 생각해보면 빈 의존성 배열 []
은 이 이펙트가 컴포넌트가 마운트 될 때만 채팅방과 연결하고 컴포넌트가 언마운트될 때만 연결을 끊는다는 것을 의미해요. (리액트는 여전히 로직에 대한 강도 테스트를 진행하기 위해 개발 모드에서는 추가로 한 번 더 재동기화 시킨다는 사실을 기억하세요.)
그러나 만약 이펙트의 관점에서 생각해본다면, 마운트나 언마운트는 생각할 필요가 전혀 없어요. 중요한 것은 이펙트가 동기화를 시작하고 종료하기 위해 무엇을 해야하는지를 정해주는 거예요. 현재는 반응적인 의존성이 없어요. 그러나 만약 사용자가 roomId
나 serverUrl
을 시간이 지났을 때 변경하도록 만들고 싶다면 (그리고 이들이 반응적이게 된다면) 이펙트의 코드는 바뀌지 않아요. 이들을 의존성에만 넣어주면 돼요.
All variables declared in the component body are reactive | 컴포넌트 바디에서 선언된 모든 변수는 반응적이다
props와 상태만이 반응값이 아니에요. 이들에게서 계산되는 모든 값들도 반응적이에요. 만약 props나 상태가 변한다면 컴포넌트는 리렌더링되고 이들에게서 계산되는 값도 변해요. 이펙트에서 사용되는 컴포넌트 내부의 모든 변수가 이펙트의 의존성 배열에 들어가야하는 이유가 바로 이 때문이에요.
사용자가 드롭다운으로 채팅 서버를 고르지만 설정에서 기본 서버를 설정할 수 있다고 해볼게요. 이미 설정에 대한 상태변수를 컨텍스트에 추가하였기 때문에 컨텍스트에서 settings
를 읽을 수 있다고 가정할게요. 이제 props와 기본 서버에서 선택된 서버에 기반하여 serverUrl
를 계산해보세요.
function ChatRoom({ roomId, selectedServerUrl }) { // roomId는 반응적이에요
const settings = useContext(SettingsContext); // settings는 반응적이에요
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl은 반응적이에요
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이펙트는 roomId와 serverUrl를 읽어요
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 따라서 이들이 변할 때 재동기화가 필요해요
// ...
}
이 예시에서 serverUrl
은 prop이나 상태 변수가 아니에요. 렌더링을 하는 동안 계산되는 일반 변수에요. 그러나 렌더링 동안 계산되기 때문에 리렌더링 동안 변할 수 있어요. 이러한 이유로 인해 이 변수는 반응적이에요.
컴포넌트 안의 모든 값(props, 상태 그리고 컴포넌트 바디의 변수)은 반응적이에요. 어떤 반응적인 값도 리렌더링을 바꿀 수 있기 때문에 이펙트의 의존성에 반응값을 포함시켜야해요.
다른 말로 하면, 리액트는 컴포넌트 바디의 모든 값에 "반응"해요.
전역 또는 변경 가능한 변수도 의존성이 될 수 있나요?
더보기(전역 변수를 포함한) 변경이 가능한 변수는 반응값이 아니에요.
location.pathname
과 같이 변경 가능한 값은 의존성이 아니에요. 이는 변이가 가능하기 때문에 리액트 렌더링 데이터 흐름 바깥에서 언제나 완전히 변경할 수 있어요. 이를 바꾸는 것은 컴포넌트의 리렌더링을 발생시키지 않아요. 따라서 만약 의존성으로 이런 변수를 지정하더라도 리액트는 이 값이 변경 되었을 때 이펙트를 재동기화 해야하는지를 몰라요. 또한 이는 리액트의 규칙을 어기는 것이기 때문에 렌더링을 하는 동안 (의존성을 계산할 때) 변경이 가능한 값을 읽는 것은 렌더링의 순수성을 해쳐요. 대신,useSyncExternalStore
로 외부의 변경 가능한 값을 읽거나 구독하세요.
ref.current
와 같은 변이 가능한 값이나 이를 통해 읽은 무언가도 의존성이 될 수 없어요.useRef
를 통해 반환된 ref 객체는 의존성이 될 수도 있지만current
속성은 의도적으로 변경할 수 있어요. 이는 리렌더링을 발생시키지 않고 무언가를 추적하도록 만들어줘요. 그러나 이를 바꾸는 것이 리렌더링을 발생시키지 않기 대문에 반응 값이 아니고 리액트는 이 값이 변했을 때 이펙트를 리렌더링해야한다는 것을 알지 못해요.
이 페이지의 아래쪽에서 배울 건데, 린터는 이러한 이슈를 자동적으로 확인해줘요.
React verifies that you specified every reactive value as a dependency | 리액트는 모든 반응형 값을 의존성으로 지정했는지를 검사한다
만약 린터가 리액트에 맞춰져있다면 이펙트에서 사용되는 모든 반응값은 의존성으로 선언되었는지를 확인할 거예요. 아래의 예시에서는 roomId
와 serverUrl
이 모두 반응적이기 떄문에 린트 에러가 발생해요.
리액트 에러처럼 보이지만 실제로 리액트는 코드의 버그를 지적하고 있어요. roomId
와 serverUrl
은 언젠가 변할 수도 있지만 이들이 변했을 때 이펙트를 재동기화 시키는 것을 까먹었어요. 사용자가 UI에서 다른 값을 선택한 이후에도 초기 roomId
와 serverUrl
과의 연결을 유지해요.
이 버그를 수정하려면 린터의 제안을 따라서 roomId
와 serverUrl
을 이펙트의 의존성으로 지정하세요.
function ChatRoom({ roomId }) { // roomId는 반응값이에요
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
}
이제 위의 샌드박스에서 에러를 해결해보세요. 린터 에러가 사라지면 채팅이 필요할때 재연결 되는 것을 확인해보세요.
노트
여러 경우에서 리액트는 컴포넌트 안에 선언된 값이라도 할지라도 값이 절대 변경되지 않는다는 사실을 알고 있어요.useState
에서 반환된set
함수나useRef
에서 반환된 ref 객체는 안정적이에요. 이 값은 리렌더링이 되어도 변경되지 않는다는 것이 보장되는 값들이에요. 안정적인 값은 반응적이지 않기 때문에 목록에서 생략할 수 있어요. 이들을 포함하는 것도 허용돼요. 이 값들은 변하지 않기 때문에 상관 없어요.
What to do when you don’t want to re-synchronize | 재동기화를 시키고 싶지 않을 때 해야할 일
이전의 예시에서 roomId
와 serverUrl
을 의존성 목록에 추가하여 린트 에러를 고쳤어요.
하지만 이러한 값들이 반응값이 아님을 린터에게 증명하는 방법도 있어요. 리렌더링의 결과로 변할 수 없다고 알려주는 것처럼요. 예를 들어, 만약 serverUrl
과 roomId
이 렌더링에 의존하지 않고 항상 같은 값을 가진다면 컴포넌트 외부로 이동할 수 있어요. 그러면 의존성이 될 필요가 없어요.
const serverUrl = 'https://localhost:1234'; // serverUrl은 반응값이 아니에요
const roomId = 'general'; // roomId는 반응값이 아니에요
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ All dependencies declared
// ...
}
이 들을 이펙트 내부로 옮길 수도 있어요. 렌더링 동안에 계산되는 값이 아니기 때문에 반응적이지 않아요.
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
// ...
}
이펙트는 코드의 반응적인 블록이에요. 읽은 값이 이펙트 안에서 바뀌면 이들을 재동기화돼요. 하나의 상호 작용이 일어나면 한 번만 실행하는 이벤트 핸들러와는 달리 이펙트는 동기화가 필요할 때마다 실행돼요.
의존성을 "선택"할 수 없어요. 의존성은 이펙트에서 읽는 모든 반응값을 포함해야만해요. 린터가 이를 강제해요. 때떄로 무한 루프와 같은 문제를 일으키거나 너무 자주 이펙트를 재동기화 시킬 떄도 있어요. 린터를 막아서 이러한 문제를 해결하지 마세요! 시도해볼 수 있는 방법이 더 있어요.
- 이펙트가 독립적인 동기화 과정을 표현하는지를 확인하세요. 만약 이펙트가 어느것과도 동기화 되지 않는다면 불필요한 이펙트인거에요. 만약 여러 독립적인 개체들과 동기화하고 있다면 분리하세요.
- 만약 props나 상태의 최신값을 반응이나 이펙트의 재동기화 없이 읽고 싶다면 이펙트를 반응적인 부분(이펙트를 유지해야하는 부분)과 반응적이지 않은 부분(이펙트 이벤트라고 불리는 무언가로 추출할 부분)으로 분리하세요. 이펙트에서 이벤트를 분리하는 것에 대해 더 자세히 알고 싶다면 여기를 읽어보세요.
- 의존성으로 객체와 함수에 의존하는 것을 삼가세요. 만약 객체와 함수를 렌더링 동안 생성한 후 이들을 이펙트에서 읽어온다면, 매 렌더링마다 이 값들이 달라질 거예요. 이는 이펙트가 매 순간마다 재동기화하도록 만들어줘요. 불필요한 의존성을 이펙트에서 제외하는 것에 대해 더 자세히 알고 싶다면 여기를 읽어보세요.
함정
린터는 여러분의 친구지만 힘은 제한적이에요. 린터는 의존성이 잘못되었을 때만 알아요. 이런 경우를 해결하는 최고의 방법은 몰라요. 만약 린터가 의존성을 제안하지만 이 의존성을 추가했을 때 반복이 발생한다 하여도 린터를 무시해야한다는 것을 의미하진 않아요. 이펙트 내부(또는 외부)의 코드를 변경하여 값이 반응적이지 않고 의존성이 될 필요가 없도록 만들어야해요.
만약 기존의 코드를 갖고 있다면 아래와 같인 린터를 막을 수도 있어요.
useEffect(() => { // ... // 🔴 아래와 같이 린터를 막는 것은 삼가세요. // eslint-ignore-next-line react-hooks/exhaustive-deps }, []);
다음 장에서 규칙을 어기지 않으면서 코드를 수정하는 방법을 알아볼 거예요. 이는 항상 수정할 가치가 있어요.
Recap | 요약
- 컴포넌트는 마운트, 업데이트 그리고 언마운트 될 수 있어요.
- 각 이펙트는 주변 컴포넌트와는 다른 생명주기를 가져요.
- 각 이펙트는 시작*하거나 *멈출 수 있는 분리된 동기화 과정을 표현해요.
- 이펙트를 쓰거나 읽을 때, 컴포넌트의 관점(어떻게 마운트, 업데이트, 언마운트 되는지) 이 아닌 각 이펙트의 관점(동기화를 어떻게 시작하고 중단하는지)에서 생각하세요.
- 컴포넌트 바디 안에서 선언된 값은 "반응적"이에요.
- 반응값은 변할 수 있기 때문에 이펙트를 재동기화시켜요.
- 린터는 이펙트 안에서 사용된 모든 반응값이 의존성으로 지정되었는지를 확인해요.
- 린터에 표시된 모든 에러는 정당한 에러해요. 규칙을 어기지 않고 코드를 수정할 방법은 항상 있어요.
Challenges | 도전 과제
1. 모든 키보드 입력마다 재연결하는 문제 해결하기
이 예시에서 ChatRoom
컴포넌트는 컴포넌트가 마운트 될 떄 채팅방과 연결하고, 언마운트 될 때 연결을 끊으며, 다른 채팅방을 선택할 떄 재연결돼요. 이러한 동작은 올바르기 때문에 이대로 두면 돼요.
그러나 문제가 하나 있어요. 메시지 박스에 타이핑을 할 떄마다 ChatRoom
또한 채팅과 재연결돼요. (콘솔창을 지우고 무언가를 타이핑하면 볼 수 있어요. 이 문제를 해결하여 이런 일어나지 않도록 하세요.
Hint
이 이펙트의 의존성 배열에 무언가를 추가해야해요. 어떤 의존성이 있어야할까요?
Solution
이 이펙트는 의존성 배열에 아무것도 갖고 있지 않았기 때문에 매 리렌더링마다 재동기화돼요. 먼저 의존성 배열을 추가하세요. 의존성에서 사용된 모든 반응형 값은 배열에 지정되어야해요. 예를 들어, roomId
는 (prop이 아니기 때문에) 반응값이기 때문에 배열에 포함되어야해요. 사용자가 다른 방을 선택하면 채팅방이 재연결된다는 것을 보장해줘요. 반면 serverUrl
은 컴포넌트 외부에서 정의되었어요. 이 변수는 배열에 들어갈 필요가 없는 이유에요.
2. 동기화 켜고 끄기
이 예시에서 이펙트는 화면의 분홍색 점을 움직이기 위해 윈도우의 pointermove
이벤트를 구독해요. 미리보기 부분에서 호버를 해보고 (모마일 기기라면 폰으로 눌러보고) 이동을 분홍색 점이 어떻게 따라가는지 보세요.
여기에는 체크박스도 있어요. 체크 박스를 누르면 canMove
상태 변수가 토글링 되지만 이 상태 변수는 코드의 모든 곳에서 사용되지 않아요. 여러분의 과제는 코드를 수정하여 conMove
가 false
(체크박스가 off에 표시)라면 점이 이동하지 않도록 만드는 거예요. 체크박스를 다시 토글하면 (그리고 canMove
가 true
라면) 박스는 다시 이동을 따라야만해요. 다른 말로하면, 점이 이동 가능 여부는 체크박스가 체크 되었는지만을 따르지 않아요.
Hint
이펙트를 조건부로 호출할 수는 없어요. 그러나 이펙트 내부의 코드는 조건문을 가질 수 있어요.
Solution
setPosition
호출을 조건문 if (canMove) { ... }
로 감싸는 거예요.
다른 방법으로는 이벤트 구독 로직을 if (canMove) { ... }
조건문으로 감싸는 거예요.
위의 두 경우에서 canMove
는 이펙트에서 읽는 반응값이에요. 이는 canMove
가 이펙트 의존성 목록으로 지정되어야하는 이유에요. 이렇게 하면 이펙트는 canMove
의 값이 변할 떄마다 재동기화해요.
3. 오래된 값 버그를 조사하기
이 예시에서 분홍 점은 체크박스가 켜지면 이동하고 꺼지면 멈춰요. 이 로직은 이미 구현되어있어요. handleMove
라는 이벤트 핸들러는 상태 변수 canMove
를 확인해요.
그러나 여러 이유로 handleMove
안의 상태 변수 canMove
는 "오래된" 값처럼 보여요. 체크박스가 설정되도 항상 true
에요. 왜 이런일이 일어날까요? 코드에서 실수를 찾고 수정하세요.
Hint
만약 린터가 막힌 것처럼 보인다면 해제하세요. 실수가 발생하는 지점이 보통은 이런 지점이에요.
Solution
기존 코드의 문제점은 의존성 린터를 막았다는 점이에요. 만약 이 부분을 제거한다면 이 이펙트가 handleMove
함수에 의존한다는 것을 볼 수 있어요. handleMove
는 컴포넌트 바디에서 선언되었고 이는 반응값이기 때문에 말이 되는 일이에요. 반응 값은 의존성으로 지정되어야만하고, 그렇지 않다면 항상 오래된 값을 가질 가능성이 있어요.
기존 코드를 작성한 사람은 리액트에게 이펙트가 어던 반응값에도 의존하지 않는다고([]
) 말해서 리액트를 속이고 있어요. 리액트가 canMove
가 변경되었음에도 (그리고 handleMove
도 이로 인해 변경되었음에도) 이펙트를 재동기화시키기 않았던 이유가 바로 여기에 있어요. 리액트는 이펙트를 재동기화하지 않았기 때문에 리스너에 붙어있는 handleMove
는 최초 렌더링에서 생성된 handleMove
함수에요. 초기 렌더링동안 canMove
는 true
이기 때문에 초기 렌더링의 handleMove
는 영원히 이 값을 보게 돼요.
린터를 막지 않았다면 기존 값들에서 발생한 문제를 볼 수 없었을 거예요. 이 버그를 해결할 수 있는 방법이 몇 개 있지만 린터를 막는 구문을 제거하는 것부터 시작해야해요. 그리고 나서 코드를 수정해야해요.
이펙트 의존성을 [handleMove]
로 변경할 수는 있지만 이 또한 매 렌더링마다 새롭게 정의되는 함수이기 때문에 의존성 배열도 지워야해요. 그리고 나서 이펙 =트는 매 렌더링마다 재동기화 될거예요.
이 해결책은 잘 동작하지만 이상적이진 않아요. 만약 console.log('Resubscribing')
을 이펙트 안에 넣는다면 매 리렌더링마다 재구독되는 것을 알 수 있어요. 재구독은 빠르지만 자주 발생하는 일은 피하는 게 좋아요.
더 나은 해결책은 handleMove
함수를 이펙트 안으로 옮기는 거예요. 그러면handleMove
는 반응 값이 아니기 때문에 이펙트는 함수에 의존할 필요가 없어요. 대신 이제 이펙트 안에서 읽는 canMove
에 의존해야해요. 이렇게 되면 이펙트는 canMove
의 값과 동기화되기 때문에 여러분들이 원하는 동작을 수행할 거예요.
console.log('Resubscribing')
을 이펙트 안에 추가해서 이제 체크박스를 토글하거나 (canMove
의 변경) 코드가 수정될 때만 재구독 되는 것을 확인하세요. 항상 재구독하는 이전의 방법보다는 나아요.
이런 문제에 대한 더욱 일반적인 접근법은 이펙트에서 이벤트 분리하기에서 배울 거예요.
4. 연결 스위치 고치기
이 예시에서 chat.js
의 채팅 서비스는 두 개의 API를 보여줘요. createEncryptedConnection
과 createUnencryptedConnection
에요. 루트 App
컴포넌트는 사용자가 암호를 사용할 것인지 사용하지 않을 것인지를 선택하도록 하고 그 다음에 해당하는 API 메서드를 createConnection
prop으로 자식 컴포넌트인 ChatRoom
컴포넌트에 전달해요.
처음에는 콘솔창의 로그는 연결이 잠겨있지 않다고 할 거예요. 체크박스를 토글링해도 아무 일도 일어나지 않아요. 하지만 만약 그 이후에 선택된 방을 변경한다면 채팅은 다시 연결되고 암호화가 가능해져요. (콘솔 메시지로 볼 거예요.) 이것이 버그에요. 버그를 고쳐서 체크박스를 토글링해도 채팅이 다시 연결되도록 만들어보세요.
Hint
린터를 막는 것은 항상 의심스러워요. 이 부분이 버그가 될 수 있나요?
Solution
만약 린터를 막은 뭉구를 지우면, 린트 에러를 보게 될 거예요. 문제는 createConnection
이 prop이기 떄문에 이 값이 결국은 반응값이라는 점이에요. 이는 언제나 변경될 수 있는 값이에요. (게다가 유저가 체크박스를 선택하면 부모 컴포넌트는 createConnection
prop으로 다른 값을 전달해요.) 의존성이 되어야하는 이유가 바로 이 때문이에요. 의존성 배열에 이 prop을 추가하여 버그를 해결하세요.
createConnection
은 의존성이 되어야해요. 그러나 이 코드는 약간 위험해요. 누군가 App
컴포넌트를 이 prop의 값으로 인라인 함수를 전달하도록 수정할 수도 있어요. 이런 경우, 그 값은 App
컴포넌트가 리렌더링 될 때마다 변경되기 때문에 너무 자주 재동기화 돼요. 이런 문제를 해결하기 위하여 isEncrypted
를 아래로 내려줘야해요.
이 버전에서 App
컴포넌트는 함수가 아니라 불리안 타입의 prop을 전달해요. 이펙트 안에서 어떤 함수가 실행될 것인지를 결정해요. createEncryptedConnection
과 createUnencryptedConnection
가 모두 컴포넌트 외부에서 선언되었기 때문에 이들은 반응값이 아니며 의존성이 될 필요가 없어요. 이에 대해서는 이펙트의 의존성 지우기에서 더 배울 거예요.
5. 셀렉트 박스의 체인을 채우기
이 예시에는 두 개의 셀렉트 박스가 있어요. 사용자가 행성을 선택하는 박스와 해당 행성에서의 장소를 선택하는 셀렉트 박스에요. 두 번째 박스는 아직 작동하지 않아요. 여러분의 과제는 선택한 행성의 장소들을 보여주도록 만드는 거예요.
첫 번째 셀렉트 박스가 어떻게 동작하는지를 살펴보세요. planetList
상태를 "/planets"
라는 API 호출의 결과로 채워요. 현재 선택된 행성의 ID는 planetId
라는 상태 변수에 저장돼요. 추가적인 코드를 추가할 곳을 찾아서 placeList
상태 변수가 /planets/" + planetId + "/places"
API 호출의 결과로 채우세요.
만약 잘 구현했다면 행성을 선택하는 것으로 장소 목록을 채울 수 있어요. 행성을 변경하면 장소 목록도 변경되어야 해요.
Hint
만약 두개의 독립적인 동기화 프로세스가 있다면 이 둘을 각기 다른 이펙트로 분리하세요.
Solution
독립적인 동기화 과정이에요.
- 첫 번째 셀렉트 박스는 행성 목록을 조종하기 위해 동기화돼요.
- 두 번째 셀렉트 박스는 현재
planetId
의 장소 목록을 조종하기 위해 동기화 돼요.
이러한 이유로 인해 이들을 두 가지 다른 이펙트로 표현하는 것이 좋아요. 예시를 보여줄게요.
이 코드는 반복적이에요. 그러나 하나의 이펙트로 이들을 합치는 것이 좋지 않은 이유는 이 때문이 아니에요. 만약 하나의 이펙트로 분리한다면, 이펙트의 두 의존성을 하나의 리스트에 넣어서 섞고 행성을 바꾸는 것은 행성의 목록과 다시 페칭해야한해요. 이펙트는 좋은 코드에 재사용할만한 도구가 아니에요.
대신, 반복을 줄이기 위하여 useSelectOption)
와 같은 커스텀 훅으로 로직을 추출하세요.
useSelectedOptions.js
탭을 샌드박스 안에서 클릭하여 어떻게 동작하는지 확인하세요. 이상적으로 어플리케이션의 대부분의 앱은 혼자 작성했든 커뮤니티에서 작성되었든 결국 커스텀 훅으로 대체되어야만해요. 커스텀 훅은 동기화 로직을 감췄기 때문에 컴포넌트를 호출하는 것을 이펙트가 몰라요. 앱에서 작업을 진행중일 때, 사용할 훅 팔레스틀 개방했고 결국 이펙트를 컴포넌트 안에서 그렇게 자주 쓸 일이 없을거예요.
'리액트 공식문서 | React Docs > Learn > Learn React' 카테고리의 다른 글
[Escape Hatches] You Might Not Need an Effect | 이펙트가 필요하지 않을 수도 있어요 (0) | 2024.03.05 |
---|---|
[Escape Hatches] Synchronizing with Effects | 이펙트와 동기화하기 (0) | 2024.03.04 |
[Escape Hatches] Manipulating the DOM with Refs | ref로 DOM 조작하기 (0) | 2024.03.03 |
[Escape Hatches] Referencing Values with Refs | ref로 값 참조하기 (0) | 2024.03.03 |
[Managing State] Scaling Up with Reducer and Context | 리듀서와 컨텍스트로 확장하기 (1) | 2024.02.28 |