어떤 컴포넌트는 외부 시스템과 동기화할 피료아 있어요. 리액트의 상태에 기반하여 리액트가 아닌 컴포넌트를 조작하고 싶거나 서버 연결을 설정하거나 컴포넌트가 화면에 나타날 때 통계 로그를 전송하고 싶을 때가 그 예시에요. 이펙트는 렌더링 이후에 코드를 실행시켜주기 때문에 리액트 바깥의 시스템과 컴포넌트를 동기화할 수 있어요.
이 페이지에서는
- 이펙트가 무엇인지
- 이펙트는 이벤트와 어떻게 다른지
- 컴포넌트에서 이펙트를 어떻게 선언하는지
- 어떻게 불필요한 이펙트를 재실행하지 않도록 하는지
- 이펙트가 개발모드에서 두 번씩 실행되는 이유가 무엇이고 이를 어떻게 해결하는지
를 알아볼 거예요.
What are Effects and how are they different from events? | 이펙트가 무엇이고 이벤트와는 어떻게 다를까요?
이펙트를 알아보기 전에 리액트 컴포넌트에서 두가지 종류의 로직에 익숙해져야해요.
- (UI 표현하기에서 소개된) 렌더링 코드는 컴포넌트의 최상위에 있어요. 이는 props와 상태를 받고, 이들을 변형하고, 화면에 보여주고 싶은 JSX를 리턴하는 곳이에요. 렌더링 코드는 순수해야해요. 수학 공식처럼 결과를 계산만 할 뿐 다른 무엇도 하지 않아요.
- (상호작용 추가하기에서 소개된) 이벤트 핸들러는 단순한 계산이 아니라 무엇가를 수행하는 컴포넌트 내부의 중첩 함수에요. 이벤트 핸들러는 입력창을 업데이트하고, 상품을 구매하는 HTTP POST 요청을 제출하거나 사용자를 다른 화면으로 이동시켜줘요. 이벤트 핸들러는 (버튼을 클릭하거나 타이핑하는 것과 같은) 사용자의 특정 행동으로 발생된 "사이드 이펙트"를 포함해요. (사이드 이펙트는 프로그램의 상태를 바꿔요.)
하지만 이것만으로는 충분하지 않을 떄가 있어요. 화면에 보이든 보이지 않든 채팅 서버와 연결되어 있어야하는 ChatRoom
컴포넌트를 생각해보세요. 서버를 연결하는 것을 순수한 계산이 아니에요. (사이드 이펙트에요.) 따라서 렌더링 동안에는 발생할 수 없어요. 그러나 클릭과 같이 ChatRoom
이 보이도록 만들어주는 특정 이벤트가 있는건 아니에요.
이펙트는 특정 이벤트가 아니라 렌더링 자체로 발생하는 사이드 이펙트를 지정해줘요. 채팅방에서 메시지를 보내는 것은 사용자가 특정 버튼을 누르는 것으로 직접 발생하기 떄문에 이는 이벤트에요. 그러나 서버 연결을 설정하는 것은 컴포넌트가 나타나도록 만드는 어떤 상호작용과 상관 없이 일어나기 떄문에 이펙트에요. 이펙트는 화면이 업데이트 된 후 커밋이 끝날 때 발생해요. 이 타이밍이 (네트워크나 서드 파티 라이브러리와 같은) 외부 시스템과 리액트를 동기화시키기 좋은 타이밍이에요.
노트
이곳과 이 문서의 마지막 부분에서 "이펙트"는 렌더링으로 발생하는 사이드 이펙트와 같이 리액트에서 지정된 정의를 참조해요. 조금 더 넓은 프로그래밍적 콘셉트를 이야기할때는 "사이드 이펙트"라고 할게요.
You might not need an Effect | 이펙트가 필요하지 않을 수도 있어요.
컴포넌트에 이펙트를 추가하려고 서두르지 마세요. 이펙트는 전형적으로 리액트 코드를 "떠나서" 외부 시스템과 동기화할 때 사용돼요. 이는 브라우저 API, 서드 파티 위젯, 네트워크 등등을 포함해요. 만약 이펙트가 다른 상태에 기반하여 어떤 상태에만 적용된다면 이펙트를 사용할 필요는 없어요.
How to write an Effect | 이펙트를 작성하는 방법
이펙트를 작성하려면 이 세 단계를 따라야해요.
- 이펙트를 선언하세요. 기본적으로 이펙트는 매 렌더링 이후에 실행돼요.
- 이펙트 의존성을 지정하세요. 대부분의 이펙트는 모든 렌더링 마다 실행되는 것이 아니라 필요할 때만 재실행돼요. 예를 들어 페이드인 애니메이션은 컴포넌트가 보일 때만 발생해요. 채팅방에 연결하거나 연결을 끊는 것은 컴포넌트가 보일 떄 또는 보이지 않을 떄, 아니면 채팅방이 바뀔 때만 발생해요. 의존성을 지정하여 어떻게 이렇게 조작하는지 배워볼게요.
- 필요하다면 클린업 함수를 추가하세요. 어떤 이펙트는 그들이 한 모든 동작을 멈추고, 되돌리고, 클린업하는 방법을 지정해야해요. 예를 들어 "연결"은 "연결 끊기"가 필요하고, "구독"은 "구독 취소"가 필요하며, "페치"는 "취소"와 "무시"가 모두 필요해요. 클린업 함수를 반환하여 이를 어떻게 하는지 배울 거예요.
각 단계를 자세하게 살펴볼게요.
Step 1: Declare an Effect | 1단계: 이펙트 선언하기
컴포넌트에서 이펙트를 선언하려면 useEffect
훅을 리액트에서 불러오세요.
import { useEffect } from 'react';
그리고 나서 컴포넌트의 최상위에서 호출하고 이펙트에 코드를 추가하세요.
function MyComponent() {
useEffect(() => {
// 이 코드는 렌더링이 될 때마다 실행돼요.
});
return <div />;
}
컴포넌트가 렌더링 될 때마다 리액트는 화면을 업데이트하고 useEffect
안의 코드를 실행해요. 즉, useEffect
는 렌더링이 화면에 반영될 떄까지 코드의 일부가 실행되는 것을 지연시켜요.
외부 시스템과 동기화하기 위해 이펙트를 사용하는 방법을 알아볼게요. <VideoPlayer>
라는 리액트 컴포넌트를 살펴볼게요. isPlaying
이라는 prop을 전달하여 비디오를 재생하거나 멈추는 것을 조작하면 좋을 것 같아요.
<VideoPlayer isPlaying={isPlaying} />;
직접 만든 videoPlayer
컴포넌트는 브라우저에 내장된 <video>
태그를 렌더링 해요.
function VideoPlayer({ src, isPlaying }) {
// 할일: isPlaying으로 무언가 실행하세요.
return <video src={src} />;
}
그러나 브라우저의 <video>
태그는 isPlaying
이라는 prop을 갖고 있지 않아요. 이를 조작하기 위해서는 DOM 엘리먼트에서 직접 play()
나 pause()
메서드를 호출해야해요. play()
나 pause()
와 같은 호출을 사용하여 비디오가 현재 재생중인지를 알려주는 isPlaying
의 값과 동기화해야해요.
먼저 <video>
DOM 노드에서 ref를 가져와야해요.
play()
나pause()
를 렌더링 하는 동안 호출하고 싶을 수 있지만 잘못된 방법이에요.
이 코드가 동작하지 않는 이유는 렌더링하는 동안 DOM 노드로 무언가를 하려고 시도했기 때문이에요. 리액트에서 렌더링은 JSX의 순수한 계산이어야하고 DOM을 수정하는 것과 같은 사이드 이펙트를 포함해서는 안돼요.
더하여 VideoPlayer
가 처음에 호출될 때 DOM 노드는 아직 존재하지 않아요! 리액트는 JSX가 반환되기 전까지 어떤 DOM이 생성될지 모르기 떄문에 play()
나 pause()
를 호출할 DOM 노드가 없어요.
이를 해결하려면 useEffect
로 사이드 이펙트를 감싸서 렌더링 계산의 바깥으로 이동시키세요.
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
이펙트로 DOM 노드를 감싸면 리액트는 화면을 먼저 업데이트하고 이펙트를 실행해요.
VideoPlayer
컴포넌트가 렌더링되면 (그게 최초 렌더링이든 리렌더링이든) 몇 가지 일이 발생해요. 먼저, 리액트는 <video>
태그가 알맞은 prop을 가지고 DOM 안에 있다고 생각하고 화면을 업데이트해요. 그리고나서 리액트는 이펙트를 실행해요. 마지막으로 이펙트는 isPlaying
의 값에 따라 play()
혹은 pause()
를 호출해요.
재생 또는 일시정지를 여러분 눌러보세요. 그리고 비디오 플레이어가 isPlayeing
값과 어떻게 동기화된 채로 유지되는지를 확인해보세요.
이 예시에서 리액트와 동기화할 "외부 시스템"은 브라우저 미디어 API에요. 비슷한 접근을 선언적 리액트 컴포넌트로 (jQuery 플러그인과 같은) 리액트가 아닌 레거시 코드를 감싸서 사용할 수 있어요.
비디오 플레이어를 조작하는 것이 실제로는 훨씬 더 복잡하다는 것을 기억하세요. play()
를 호출하는 것을 실패하거나 사용자가 내장된 브라우저 컨트롤을 사용하여 재생하거나 일시정지 하는 등의 변수가 발생해요. 이 예시는 굉장히 단순하며, 완벽하지 않아요.
함정
기본적으로 이펙트는 매 렌더링마다 실행돼요. 아래와 같은 코드는 무한 루프가 생성돼요.
const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); });
이펙트는 렌더링의 결과*로 실행해요. 상태를 설정하는 것은 렌더링을 *발생시켜요. 상태를 직접 이펙트 안에서 설정하면 자신의 콘센트에 다시 코드를 꽂는 것과 같아요. 이페긑가 실행되면 상태가 설정되고 이는 리렌더링을 발생시키며 이는 다시 이펙트가 실행되도록 만들어요. 그러면 상태는 다시 설정되고 또 다른 리렌더링이 발생해요. 이 과정에 계속 반복할 거예요.
이펙트는 보통 컴포넌트를 외부 시스템과 동기화시켜줘요. 만약 외부 시스템이 없고 다른 상태에 기반하여 어떤 상태에만 적용하고 싶다면 이펙트를 사용할 필요는 없어요.
Step 2: Specify the Effect dependencies | 2단계: 이펙트 의존성 지정하기
기본적으로 이펙트는 매 렌더링 후에 실행돼요. 보통은 이렇게 동작하기를 바라진 않을 거예요.
- 때때로, 이런 작동은 느려요. 외부 시스템과 동기화하는것은 항상 즉각적으로 처리되지 않기 떄문에 불필요하다면 이 과정을 건너뛰고 싶을 거예요. 예를 들어 키보드를 누를 때마다 채팅 서버를 재연결하고 싶진 않을 거예요.
- 때때로, 이런 작동은 잘못된 작동이에요. 예를 들어 키보드를 누를 때마다 컴포넌트의 페이드인 애니메이션을 발생시키고 싶지는 않을 거예요. 애니메이션은 맨 처음 컴포넌트가 보일 때에만 실행되어야해요.
이 이슈를 입증하기 위해 몇개의 console.log
호출과 부모 컴포넌트의 상태를 업데이트 하는 텍스트 입력창을 포함한 이전 예시를 보여줄게요. 타이핑을 하면 이펙트가 재실행된다는 것을 확인할 수 있어요.
useEffect
호출의 두 번째 인자로 의존성 배열을 지정하여 이펙트가 불필요하게 재실행되는 것을 건너뛸 수 있어요. 위 예시의 14번째 줄에 []
라는 빈 배열을 추가하는 것부터 시작해보세요.
useEffect(() => {
// ...
}, []);
리액트 훅인 useEffect에 isPlaying이라는 의존성이 빠졌어요.
라는 에러 문구를 볼 수 있어요.
무엇을 해야할지를 결정하기 위해 이펙트가 isPlaying
prop에게 의존하고 있지만 이 의존성이 명시적으로 선언되지 않았기 때문에 이런 문제가 발생해요. 이 문제를 해결하려면 의존성 배열에 isPlaying
을 추가하세요.
useEffect(() => {
if (isPlaying) { // 여기서 사용되기 때문에,
// ...
} else {
// ...
}
}, [isPlaying]); // 이곳에서도 선언해주어야해요.
이제 모든 의존성이 선언되었기 때문에 에러가 발생하지 않아요. 의존성 배열로[isPlaying]
을 지정해주면 이전 렌더링과 isPlaying
값이 같으면 리액트는 이펙트를 재실행하는 과정을 건너뛰어요. 이 변화로 입력창에 타이핑을 하면 이펙트가 재실행되지는 않지만 재생/일시정지 버튼을 누르면 이펙트가 다시 실행돼요.
의존성 배열은 여러개의 의존성을 포함할 수 있어요. 리액트는 여러분이 지정한 모든 의존성이 이전 렌더링에서 가졌던 값과 정확히 동일할 때만 이펙트를 재실행시키지 않아요. 리액트는 Object.js를 사용하여 의존성 값을 비교해요. useEffect
문서를 보면 더 자세히 알 수 있어요.
의존성 배열은 "선택"할 수 없다는 사실을 기억하세요. 만약 지정한 의존성이 이펙트의 코드에 기반하여 리액트가 기대한 바와 맞지 않으면 린트 에러가 발생해요. 이는 코드의 버그를 잡을 수 있도록 도와줘요. 만약 코드가 재실행되는 것을 원치 않으면 이펙트 코드 자체가 의존성이 "필요하지 않도록" 수정하세요.
함정
의존성 배열이 아예 없을 떄와 빈 의존성 배열
[]
을 가지고 있을 때는 달라요.useEffect(() => { // 모든 렌더링 마다 실행돼요. }); useEffect(() => { // 마운트가 될 때만 실행돼요. (컴포넌트가 보일 때) }, []); useEffect(() => { // 마운트가 될 때 그리고 만약 a 또는 b가 이전 렌더링에서 바뀔 때 실행돼요. }, [a, b]);
다음 단계에서 "mount"의 의미가 무엇인지를 자세히 살펴볼 거예요.
왜 ref는 의존성 배열에서 생략될까요?
더보기이 이펙트는
ref
와isPlaying
을 사용하지만isPlaying
만 의존성 배열에 추가되어 있어요.function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } }, [isPlaying]);
왜냐하면
ref
객체는 안정적인 식별자를 갖고 있기 떄문이에요. 리액트는 모든 렌더링마다 동일한useRef
에서 동일한 객체를 항상 받는 다는 것을 보장해요. 이는 절대 변하지 않기 때문에 스스로 이펙트를 재실행하지 않아요. 그래서 ref의 포함 여부는 중요하지 않아요. 의존성 배열에 ref를 넣어도 물론 괜찮아요.function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } }, [isPlaying, ref]);
useState
에서 반환된set
함수도 안정적인 식별자를 갖고 있기 때문에 의존성 배열에서 생략되는 것을 종종 볼 수 있어요. 만약 린트가 에러 없이 의존성을 생략하도록 내버려둔다면 안전한 거예요.
항상 안정적인 의존성을 생략하는 것은 린터가 해당 객체를 안정적이라고 "생각할 때"만 동작해요. 예를 들어서 만약ref
가 부모 컴포넌트에서 전달되었다면 의존성 배열에 이를 지정해주어야해요. 그러나 부모 컴포넌트가 항상 같은 ref를 전달하는지 혹은 조건부로 몇 개의 ref 중 하나를 전달하는지 알 수 없기 때문에 이렇게하는 것이 좋아요. 그러면 이펙트가 전달받은 ref에 의존할 거예요.
Step 3: Add cleanup if needed | 3단계: 필요하다면 클린업 함수를 추가하세요.
다른 예시를 볼게요. Chatroom
컴포넌트는 나타날 때 채팅 서버와 연결되어야해요. connect()
와 disconnect()
메서드를 가지는 객체를 반환하는 createConnection()
API를 받았어요. 어떻게 하면 사용자에게 보여지는 동안 컴포넌트가 연결 상태를 유지할 수 있을까요?
이펙트 로직을 작성하는 것부터 시작해볼게요.
useEffect(() => {
const connection = createConnection();
connection.connect();
});
매 렌더링마다 채팅과 연결한다면 느려지기 때문에 의존성 배열을 추가해야해요.
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
이펙트 내부의 코드는 어떤 prop이나 상태도 사용하지 않기 때문에 의존성 배열을 빈 배열인 []
가 될 거예요. 이렇게 작성하는 것은 리액트에게 컴포넌트가 맨 처음 화면에 나타나는 순간과 같이 '마운트가 될 때만' 이 코드를 실행하라고 말해주는 거예요.
이 코드를 실행해보세요.
이 이팩트는 마운트가 될 때만 실행되기 떄문에 "✅ Connecting..."
가 콘솔에 한 번만 출력된다고 생각했을 거예요. 하지만 만약 콘솔을 확인한다면 "✅ Connecting..."
가 두 번 출력된 것을 발견할 거예요. 왜 이런 일이 발생할까요?
ChatRoom
컴포넌트는 많은 다른 화면을 가진 큰 앱의 일부라고 생각해보세요. 사용자는 ChatRoom
페이지로의 여정을 시작해요. 컴포넌트는 마운트 되고 connection.connect()
를 호출해요. 그리고나서 사용자가 다른 화면으로 이동한다고 생각해보세요. 예를 들어 환경설정 페이지 같은 곳으로요. ChatRoom
컴포넌트는 언마운트 될 거예요. 마지막으로 사용자가 뒤로가기를 누르면 ChatRoom
은 다시 마운트 돼요. 이는 두 번째 연결을 설정할거예요. 하지만 첫 번쨰 연결은 절대 파괴되지 않아요. 사용자가 다른 앱으로 이동하더라도 연결은 여전히 쌓여있어요.
이와 같은 버그는 광범위한 수동 테스트 없이는 놓치기 쉬워요. 이 버그들을 빠르게 파악하기 위해 개발 환경에서 리액트는 모든 컴포넌트를 초기 마운트 직후에 즉시 다시 한 번 마운트해요.
"✅ Connecting..."
로그를 두 번 보는 것은 실제 이슈를 파악하는데 도움이 돼요. 여러분의 코드는 컴포넌트가 언마운트 되어도 연결을 닫지 않아요.
이 문제를 해결하기 위하여 이펙트에서 클린업 함수를 반환해볼게요.
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
리액트는 이펙트가 다시 실행되기 전에, 그리고 컴포넌트가 언마운트되는(제거되는) 맨 마지막에 클린업 함수를 호출해요. 클린업 함수가 구현되면 무슨 일이 발생하는지 확인해볼게요.
이제 개발모드에서 3개의 콘솔창 로그를 확인할 수 있어요.
"✅ Connecting..."
"❌ Disconnected."
"✅ Connecting..."
이러한 동작은 개발 모드에서 발생하는 자연스러운 동작이에요. 컴포넌트가 다시 마운트 되면서 리액트는 다른 곳으로 이동하거나 뒤로 가더라도 코드가 꺠지지 않는다는 것을 증명해줘요. 연결을 끊고 다시 연결하는 것은 발생해야하는 일이 맞아요! 클린업 함수를 잘 구현하면 이펙트만을 실행하는 것과 이팩트를 실행하고 이펙트를 클린업하고 다시 이펙트를 실행하는 것의 차이를 사용자는 볼 수 없어요. 리액트는 개발모드에서 코드에 버그가 있는지를 조사하기 때문에 추가적인 연결/연결끊기 호출이 있어요. 이는 정상적이니까 없애려고 하지 마세요!
프로덕션 환경에서는 "✅ Connecting..."
가 한 번만 출력되는 것을 볼 수 있어요. 컴포넌트를 다시 마운트하는 작업은 개발 모드에서만 실행되며 이펙트가 클린업 함수가 필요하다는 것을 찾게 해줘요. 개발 모드의 행동을 걷어내기 위해 엄격한 모드를 끌 수는 있지만 그냥 켜놓는 것을 추천해요. 위의 예시에서처럼 버그를 찾는데 많은 도움이 돼요.
How to handle the Effect firing twice in development? | 개발모드에서 이펙트를 두 번 실행하는 것을 어떻게 다루나요?
리액트는 이전의 예시에서와 같이 버그를 잡기 위하여 개발 모드에서 컴포넌트를 의도적으로 다시 마운트해요. "어떻게 이펙트를 한 번만 실행하나요"가 아니라 "다시 마운트 된 이후에 동작하도록 리액트를 어떻게 고치나요?"가 맞는 질문이에요.
보통 정답은 클린업 함수를 구현하는 거예요. 클린업 함수는 이펙트가 무엇을 하고있든 중단하거나 되돌릴 수 있어요. 경험상 사용자는 이펙트가 (프로덕션 모드에서) 한 번 실행되는지 혹은 (개발 모드에서 본 것처럼) 설정 -> 클린업 -> 설정의 순서로 실행되는지를 구분하지 못해요.
여러분이 작성하는 대부분의 이펙트는 아래와 같이 자주 사용되는 패턴 중 하나에 부합할 거예요.
Controlling non-React widgets | 리액트가 아닌 위젯 조작하기
때때로 리액트로 작성되지 않은 UI 위젯을 추가해야해요. 예를 들어 페이지에 지도 컴포넌트를 추가한다고 해볼게요. setZoomLevel()
메서드가 있고 리액트 코드에서 zoomLevel
이라는 상태 변수와 줌 단계를 동기화 하고 싶어요. 이펙트는 아래 코드와 비슷할 거예요.
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
이 경우에는 클린업 함수가 필요하지 않아요. 개발 모드에서 리액트는 이펙트를 두 번 호출하지만 setZoomLevel
을 같은 값으로 두번 호출하는 것은 아무 일도 발생시키지 않기 때문에 문제가 되지 않아요. 조금 느릴 수는 있지만 프로덕션 환경에서는 굳이 마운트를 다시 하지는 않기 떄문에 상관 없어요.
몇몇 API는 연속해서 두 번 호출하는 것을 허용하지 않아요. 예를 들어 내장된 <dialog>
의 showModal
메서드는 두 번 호출하면 에러를 던져요. 클린업 함수를 구현하여 다이얼로그를 닫아보세요.
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
개발모드에서 이펙트는 showModal()
을 호출한 직후에 close()
를 호출하고, 다시 showModal()
을 호출해요. 이는 프로덕션 모드에서 보이는 것처럼 showModal()
을 한 번만 호출하는 사용자가 보는 동작과 동일해요.
Subscribing to events | 이벤트 구독하기
만약 이펙트가 무언가를 구독한다면 클린업 함수는 구독취소가 되어야만 해요.
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
개발 모드에서 이펙트는 addEventListner()
를 호출하고 나서 즉시 removeEventListner()
를 호출한 후, 다시 addEventListener()
를 같은 핸들러로 호출해요. 그래서 한 번에 하나의 구독만 활성화돼요. 이는 프로덕션 모드처럼 addEventListener()
를 한 번만 호출하는 사용자가 보는 동작과 동일해요.
Triggering animations | 애니메이션 발생시키기
만약 이펙트가 무언가에 애니메이션을 추가한다면 클린업 함수는 애니메이션을 초기 값으로 초기화해요.
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);
개발 모드에서 투명도는 1
로 설정된 후 0
으로 설정되고 다시 1
로 설정돼요. 이는 바로 1
로 설정되는 사용자가 보는 동작과 동일하고 이는 프로덕션 모드에서 발생하는 일이에요. 만약 트위닝을 지원하는 서드 파티 애니메이션 라이브러리를 사용한다면 클린업 함수는 초기 상태로 타임라인을 초기화해요.
Fetching data | 데이터 페칭하기
만약 리액트가 무언가를 페칭한다면 클린업 함수는 페치를 취소하거나 그 결과를 무시하는 함수여야해요.
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
이미 발생한 네트워크 요청을 되돌릴 수는 없지만 클린업 함수는 더는 관련이 없는 페치가 어플리케이션에 영향을 미치지 못하도록 만들어요. 만약 userId
가 'Alice'
에서 'Bob'
으로 바뀐다면 클린업 함수는 'Alice'
응답은 'Bob'
이후에 도착하더라도 무시되도록 만들어줘요.
개발 모드에서는 네트워크 텝에서 두 번 페칭되는 것을 볼 수 있어요. 여기에 문제는 없어요. 위의 방법으로 첫 번쨰 이펙트는 즉시 클린업 되기 때문에 ignore
변수의 복사본은 true
로 설정돼요. 따라서 추가 요청이 있더라도 if (!ignore)
덕분에 상태에 영향을 미치지는 않아요.
프로덕션 모드에서는 한 번의 요청만 발생해요. 만약 개발 모드의 두 번쨰 요청이 여러분을 괴롭힌다면 가장 좋은 방법은 컴포넌트 간의 요청의 중복을 없애고 이들의 응답을 캐싱하는 해결책을 사용하는 거예요.
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
이렇게 하면 개발자 경험이 향상될 뿐만 아니라 앱 또한 더욱 빨라질 거예요. 테이터는 이미 캐싱되어 있기 때문에 뒤로 가기 버튼을 누른 사용자는 데이터가 다시 로딩되기를 기다리지 않아도 돼요. 이런 캐시를 직접 구현해도 되고 이펙트에서 손수 페칭하는 방법의 대안책은 많으니 이들 중 하나를 선택해도 돼요.
이펙트에서 데이터를 사용하지 않도록 하는 좋은 대안책들에는 무엇이 있나요?
더보기이펙트 안에서
fetch
를 호출하는 방법은 데이터를 페칭하는데 자주 사용되는 방법이에요. 특히 완벽한 클라이언트 사이드 앱에서는 더욱 인기있는 방법이에요. 하지만 이는 너무 수동적인 방법이고 중요한 단점이 있어요.
- 이펙트는 서버에서 동작하지 않아요. 이는 즉 초기에 처버에서 렌더링되는 HTML은 데이터가 없이 로딩된 상태를 갖고 있다는 말과 같아요. 클라이언트의 컴퓨터는 모든 자바스크립트를 다운로드하고 지금 데이터를 로드할 필요가 있는지를 찾기 위해서만 앱을 렌더링해야해요. 이는 매우 비효율적인 방법이에요.
- 이펙트에서 직접 페칭하는 것은 "네트워크 워터폴"을 일으키기 쉬워요. 부모 컴포넌트를 렌더링하면 이 컴포넌트는 몇몇 데이터를 페칭하고, 자식 컴포넌트를 렌더링 한 후 데이터 페칭을 시작해요. 만약 네트워크가 그닥 빠르지 않다면 병렬적으로 데이터를 페칭하는 것보다 극명하게 느릴 거예요.
- 이펙트에서 직접 페칭하는 것은 보통 데이터를 미리 로딩하거나 캐싱할 수 없음을 의미해요. 예를들어 만약 컴포넌트가 언마운트 되고 다시 마운트된다면 데이터를 다시 페칭해야해요.
- 인간공학적이지 못해요. 경쟁 상태와 같은 버그에 고통받지 않는 방법으로fetch
호출을 작성할 때 약간의 보일러 플레이트가 포함돼요.
이 단점들은 리액트에만 국한되지 않아요. 어떤 라이브러리로든 데이터를 마운트할 때 가져온다면 적용되는 이야기해요. 라우팅을 사용하는 것처럼 데이터 페칭을 잘하는 것은 사소한 일이 아니에요. 따라서 우리는 아래와 같은 방법을 추천할게요.
- 프레임워크를 사용한다면 내장된 데이터 페칭 메커니즘을 사용하세요. 모던 리액트 프레임워크는 효율적이고 위와 같은 문제에 봉착하지 않는 데이터 페칭 메커니즘과 통합되었어요.
- 아니면 클라이언트 사이트 캐시를 사용하거나 빌드하는 것을 고려해보세요. React Query, useSWR 그리고 React Router 6.4+와 같이 유명한 오픈 소스들이 있어요. 여러분들도 여러분 만의 오픈소스를 만들 수도 있고 어떤 경우에는 내부동작은 이펙트를 사용할 수도 있어요. 하지만 중복 요청을 막고 응답을 캐싱하고 네트워크 워터폴을 막는 로직을 추가하세요.
이 접근 중 그 어느 것도 알맞은 방법이 없다면 이펙트에서 직접 데이터를 페칭할 수 도 있어요.
Sending analytics | 통계자료 전송하기
방문한 페이지에서 통계적인 이벤트를 전송하는 아래 코드를 보세요.
useEffect(() => {
logVisit(url); // POST 요청 보내기
}, [url]);
개발 모드에서 logVisit
는 모든 URL 마다 두 번씩 호출되기 떄문에 이를 해결하려고 할거예요. 우리는 이 코드를 그대로 두는 것을 추천해요. 이전의 예시에서처럼 한 번 실행하든 두 번 실행하든 사용자가 보는 행동에는 차이가 없어요. 실용적 관점에서는 logVisit
는 개발 모드에서 아무것도 하지 않아요. 프로덕션 지표를 왜곡하는 개발 기계에서의 로그를 수집할 필요가 없기 떄문이에요. 컴포넌트는 파일을 저장할때마다 다시 마운트되기 때문에 어쨌든 개발모드에서는 추가적인 방문이 기록돼요.
프로덕션 모드에서는 방문 로그가 중복되지 않아요.
전송한 통계적 이벤트를 디버깅하기 위해서 (프로덕션 모드에서 작동하는) 준비 환경을 배포하거나 일시적으로 엄격한 모드와 개발모드에서만 동작하는 리마운트 체킹을 끌 수 있어요. 이펙트 대신에 라우트 변경 이벤트 핸들러에서 통계자료를 전송할 수도 있어요. 더욱 정확한 통계를 위해 교차 관측을 사용하면 뷰포트 안에 있는 컴포넌트가 무엇이고 얼마동안 보여지고 있는지를 추적하는데 도움이 돼요.
Not an Effect: Initializing the application | 이펙트가 아닌 것: 어플리케이션 초기화
어떤 로직은 어플리케이션을 시작할 때 한 번만 실행돼요. 이러한 로직은 컴포넌트 외부에 넣을 수 있어요.
if (typeof window !== 'undefined') { // 브라우저에서 실행중인지 확인
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
이 코드는 이러한 로직이 브라우저가 페이지를 로드한 이후에 한 번만 실행되는 것을 보장해줘요.
Not an Effect: Buying a product | 이펙트가 아닌 것: 상품 구매
때때로 클린업 코드를 작성했더라도 이펙트가 두 번 실행되는 것을 사용자가 알아채는 일을 막을 방법이 없어요. 예를 들어 이펙트가 상품을 구매하는 것처럼 POST 요청을 전송한다고 할게요.
useEffect(() => {
// 🔴 잘못된 코드: 이 이펙트는 개발 모드에서 두 번 실행되고 코드에서 문제가 보여져요.
fetch('/api/buy', { method: 'POST' });
}, []);
상품을 두 번이나 구매하진 않을 거예요. 그러나 바로 이것이 바로 이 로직을 이펙트 안에 넣으면 안되는 이유에요. 사용자가 다른 페이지로 이동하고나서 뒤로가기를 누르면 어떻게 될까요? 이펙트는 다시 실행될 거예요. 사용자가 페이지에 방문할 때마다 상품을 구매하도록 하고 싶지는 않을 거예요. 사용자가 구매 버튼을 눌렀을 때만 상품이 구매되어야해요.
구매는 렌더링으로 발생되면 안돼요. 대신 특정한 상호작용으로 발생해요. 사용자가 버튼을 누를 때에만 작동해요. 이펙트를 삭제하고 /api/buy
요청을 구매 버튼의 이벤트 핸들러로 옮기세요.
function handleClick() {
// ✅ 특정한 상호작용으로 발생하기 떄문에 구매는 이벤트에요.
fetch('/api/buy', { method: 'POST' });
}
이는 리마운트가 어플리케이션의 로직을 깨뜨린다면 보통 이미 존재하는 버그를 드러내고 있다는 것을 보여줘요. 사용자의 관점에서 페이지를 방문하는 것은 페이지를 방문하고, 링크를 누르고 나서, 이전 페이지를 다시 보기 위해 뒤로가기를 누르는 작업과 다르지 않아요. 리액트는 컴포넌트가 개발 모드에서 한 번 더 마운트를 하여 이 원칙에 부합하다는 것을 검증해요.
Putting it all together | 이들을 모두 함께 넣기
이 운동장은 실제로 이펙트가 어떻게 작동하는지를 "느끼도록" 도와줄 거예요.
이 예시는 setTimeout
을 사용하여 이펙트가 실행된 후 3초 뒤에 콘솔창에 입력 문구가 출력되도록 스케줄링해요. 클린업 함수는 보류된 타임아웃을 취소해요. "컴포넌트 마운트하기"를 눌러서 시작해보세요.
처음에는 3개의 로그를 볼 거예요. Schedule "a" log
다음에 Cancel "a" log
그리고 다시 Schedule "a" log
를 보게돼요. 3초 후에 a
라는 로그가 생겨요. 이미 배운 것처럼, 추가적인 스케줄링/취소의 쌍은 리액트가 클린업 함수를 잘 구현했는지를 증명하기 위해 개발모드에서 컴포넌트를 한 번더 마운트하기 때문에 발생해요.
이제 abc
로 입력창을 수정해보세요. 만약 이를 충분히 빠르게 수행한다면 Schedule "ab" log
뒤에 Cancel "ab" log
와 Schedule "abc" log
가 바로 출력되는 것을 볼 수 있어요. 리액트는 항상 이전 렌더링의 리엑트를 다음 렌더링의 이펙트 전에 클린업해요. 입력창에 빠르게 타이핑하였음에도 불구하고 한 번에 최대 한 개의 타임아웃만 스케줄되는 이유가 바로 여기에 있어요. 입력창을 몇 번 수정하여 이펙트가 어떻게 클린업되는지를 확인해보세요.
무언가를 입력창에 타이핑하고 즉시 "컴포넌트 언마운트하기"를 눌러보세요. 언마운트가 마지막 렌더링에서의 이펙트를 어떻게 클린업하는지를 보세요.여기서 마지막 타임아웃은 실행될 기회를 얻기도 전에 정리돼요.
마지막으로 위의 컴포넌트를 수정하여 클린업 함수를 주석처리하세요. 그러면 타임아웃이 취소되지 않을 거예요. abcde
를 빠르게 작성하세요. 3초 후에 어떤 일이 발생할 것 같나요? 타임아웃의 console.log(text)
이 마지막 text
를 출력하고 5개의 abcde
로그를 출력할 것 같나요? 여러분의 직감을 확인하려면 한 번 시도해보세요!
3초 후에는 5개의 abcde
로그가 아니라 로그의 과정을 모두 볼 수 있어요. (a
, ab
, abc
, abcd
, abcde
) 각 이펙트는 text
값을 해당하는 렌더링에서 캡쳐해요. text
상태가 변경되었는지와는 상관이 없어요. text = 'ab'
로 렌더링될 때의 이펙트는 항상 ab
를 보고 있어요. 다른 말로 하면 다른 렌더링의 이펙트는 다른 이펙트와 독립적이에요. 만약 어떻게 동작하는지가 궁금하다면 클로저에 대해 읽어보세요.
각 렌더링은 자신만의 이펙트를 가져요
더보기useEffect
가 렌더링 결과의 일부 행동에 "붙어있다고" 생각할 수 있어요. 이 이펙트를 한 번 볼게요.
export default function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to {roomId}!</h1>; }
사용자가 이 앱을 돌아다닐 때 정확히 무슨 일이 일어나는지 확인해볼게요.
최초 렌더링
사용자는<ChatRoom roomId="general" />
를 방문해요.roomId
를'general'
로 대체해볼게요.// 최초 렌더링의 JSX (roomId = "general") return <h1>Welcome to general!</h1>;
이펙트로 렌더링 결과물의 일부에요. 초기 렌더링의 이펙트는 아래와 같아요.
// 최초 렌더링의 이펙트 (roomId = "general") () => { const connection = createConnection('general'); connection.connect(); return () => connection.disconnect(); }, // 최초 렌더링의 의존성(roomId = "general") ['general']
리액트는 이 이펙트를 실행하여
'general
' 채팅방과 연결해요.동일한 의존성으로 리렌더링하기
<ChatRoom roomId="general" />
가 리렌더링 된다고 해볼게요. JSX 결과는 동일해요.// 두 번째 렌더링의 JSX (roomId = "general") return <h1>Welcome to general!</h1>;
리액트는 렌더링 결과가 바뀌지 않았다고 보기 떄문에 DOM을 업데이트하지 않아요.
두 번째 렌더링의 이펙트는 다음과 같아요.
// 두 번째 렌더링의 이펙트 (roomId = "general") () => { const connection = createConnection('general'); connection.connect(); return () => connection.disconnect(); }, // 두 번째 렌더링의 의존성(roomId = "general") ['general']
리액트는 두 번째 렌더링의
['general']
을 첫 번째 렌더링의['general']
과 비교해요. 모든 의존성은 동일하기 때문에 리액트는 이펙트를 두 번째 렌더링에서 무시해요. 절대 호출하지 않아요.다른 의존성으로 리렌더링 하기
그리고나서 사용자는
<ChatRoom roomId="travel" />
를 방문해요. 이 번에 컴포넌트는 다른 JSX를 반환해요.// 세 번째 렌더링의 JSX (roomId = "travel") return <h1>Welcome to travel!</h1>;
리액트는 DOM을 업데이트하여
"Welcome to general"
을"Welcome to travel"
로 변경해요. 3번째 렌더링의 이펙트는 아래와 같아요.// 세 번째 렌더링의 이펙트 (roomId = "travel") () => { const connection = createConnection('travel'); connection.connect(); return () => connection.disconnect(); }, // 세 번째 렌더링의 의존성 (roomId = "travel") ['travel']
리액트는 세 번째 렌더링의
['travel']
을 두 번째 렌더링의['general']
과 비교해요. 한가지 의존성이 변경되었어요.Object.js('travel', 'general')
은false
에요. 이펙트를 건너뛰지 않아요.리액트가 세 번째 렌더링에서 이펙트를 적용하기 전에 실행되었던 마지막 이펙트를 클린업 해야해요. 두 번째 렌더링의 이펙트를 건너뛰었기 때문에 리액트는 첫 번째 렌더링의 이펙트를 클린업해야해요. 만약 첫 번째 렌더링으로 스크롤하여 올라간다면 클린업 함수가
createConnection('general')
로 생성된 connection에disconnect()
를 호출한다는 것을 알 수 있어요. 이 구문은'general'
채팅 방에서 앱의 연결을 차단해요.이후에 리액트는 세 번째 렌더링의 이펙트를 실행해요. 이렇게
'travel'
채팅방과 연결해요.언마운트
마지막으로 사용자가 떠나고
ChatRoom
컴포넌트가 언마운트된다고 할게요. 리액트는 마지막 이펙트의 클린업 함수를 실행해요. 마지막 이펙트는 세 번째 렌더링의 이펙트에요. 세 번째 렌더링의 클린업은createConnection('ravel')
connection을 없애요. 따라서 앱은'travel'
방과 연결이 끊겨요.개발 모드에서만 일어나는 동작들
엄격한 모드가 실행되면 리액트는 모든 컴포넌트를 마운트 후에 한 번씩 다시 마운트해요. (상태와 DOM은 보존돼요.) 이렇게 하면 클린업이 필요한 이펙트를 찾는데 도움을 주고 경쟁 상태와 같은 버그를 일찍 노출해줘요. 더하여 리액트는 개발 모드에서 파일을 저장할 때마다 이펙트를 다시 마운트해요. 이 동작들은 모두 개발 모드에서만 발생해요.
Recap | 요약
- 기본적으로 이펙트는 렌더링이 발생할 때마다 실행돼요. (최초 렌더링을 포함해서요.)
- 리액트는 모든 의존성이 마지막 렌더링의 값과 일치하면 이펙트를 건너 뛰어요.
- 의존성을 "선택할" 수는 없어요. 이펙트 안에 적힌 코드로 결졍돼요.
- 빈 의존성 배열(
[]
)은 화면에 컴포넌트가 추가되는 것과 같이 "마운트될 때"와 같은 의미에요. - 엄격한 모드에서 리액트는 이펙트의 강도 테스트를 위해 컴포넌트를 두 번씩 마운트해요. (개발 모드에서만요!)
- 다시 마운트가 되면서 이펙트가 망가진다면 클린업 함수를 구현해야해요.
- 리액트는 이펙트가 다음번에 실행되기 전과 언마운트되는 동안에 클린업 함수를 실행해요.
Challenges | 도전 과제
1. 마운트할 때 필드 포커싱하기
이 예시에서 폼은 <MyInput />
컴포넌트를 렌더링해요.
인풋의 focus()
메서드를 사용하여 MyInput
이 화면에 나올 때 자동적으로 포커스되도록 만들어보세요. 이미 구현부가 주석처리 되어있지만 잘 작동하지는 않아요. 작동하지 않는 이유를 찾고 고쳐보세요. (만약 autoFocus
속성에 익숙하다면 해당 속성이 없다고 생각해보세요. 같은 기능을 스크래치에서 구현해볼 거예요.)
여러분의 해결방법이 잘 작동하는지를 검증하려면 "Show form" 버튼을 눌러서 입력창이 포커싱을 잘 받는지를 확인하세요. (하이라이트가 되어야하고 커서거 그 안으로 이동되어야해요.) "Hide form" 버튼을 눌렀다가 "Show form"을 다시 눌러보세요. 입력창이 다시 하이라이트되는지 확인하세요.
MyInput
은 렌더링이 될 때마다 포커스되면 안되고 마운트가 될 때만 포커싱이 되어야해요. 이 조건이 잘 지켜졌는지를 확인하려면 "Show form"을 누르고 나서 반복적으로 "Make it uppercase" 체크박스를 눌러보세요. 체크 박스를 클릭해도 입력창이 포커싱 되서는 안돼요.
Solution
렌더링을 하는 동안 ref.current.focus()
를 호출하는 것은 사이드 이펙트이기 떄문에 틀린 코드가 돼요. 사이드 이펙트는 이벤트 핸들러 안에 위치하거나 useEffect
에서 선언되어야해요. 이 경우에서 사이드 이펙트는 특정한 상호작용이 아니라 컴포넌트가 나타나면 발생하기 때문에 이펙트 안에 넣는 것이 합리적이에요.
이를 고치려면 ref.current.focus()
호출을 이펙트 선언문 안에 넣으세요. 그리고나서 이 이펙트가 모든 렌더링 이후가 아니라 마운트 될 떄만 실행한다는 것을 보장하기 위하여 빈 배열 []
을 의존성에 추가하세요.
2. 조건부로 필드 포커싱하기
이 폼은 두 개의 <MyInput />
컴포넌트를 렌더링해요.
"Show form"을 눌러서 두 번쨰 필드가 자동적으로 포커싱되는 것을 확인하세요. 왜냐하면 두 개의 <MyEffect />
컴포넌트는 필드 내부를 포커싱하기 때문이에요. 두 입력창에서 연속적으로 focus()
를 호출하면 마지막 호출이 항상 "이겨요."
첫 번쨰 필드를 포커싱 하고 싶다고 해볼게요. 첫 번째 MyInput
컴포넌트는 이제 true
로 설정된 shouldFocus
prop을 받아요. 로직을 변경하여 만약 MyInput
에서 받은 shouldFocus
prop이 true
일 때만 focus()
가 호출되도록 만드세요.
여러분의 풀이를 검증하려면 "Show form"과 "Hide form"을 반복하여 눌러보세요. 폼이 보일 때 첫 번째 입력창만 포커싱 되어야해요. 왜냐하면 부모 컴포넌트는 첫 번째 입력창을 shouldFocus={true}
로 렌더링하고 두 번째 입력창은 shouldFocus={false}
이기 때문이에요. 또한 두 입력창이 모두 잘 동작하여 두 곳 모두에서 입력이 가능한지도 확인하세요.
Hint
조건부로 이펙트를 선언할 수는 없지만 이펙트는 조건 로직을 포함할 수 있어요.
Solution
이펙트 안에 조건 로직을 넣으세요. 이펙트 안에서 shouldFocus
를 사용하기 떄문에 shouldFocus
를 의존성으로 지정해야해요. (만약 입력창의 shouldFocus
가 false
에서true
로 바뀌면 마운트된 이후에 포커싱이 될 것임을 의미해요.)
3. 두 번씩 실행되는 간격 수정하기
Counter
컴포넌트는 매 초마다 증가하는 카운터를 보여줘요. 마운트가 될 때 setInterval
을 호출해요. 이는 onTick
을 매초마다 실행시켜요. onTick
함수는 카운터를 증가시켜요.
그러나 매 초마다 1씩 증가하지 않고 2씩 증가해요. 왜 그럴까요? 버그의 원인을 찾고 고쳐보세요.
Hint
setInterval
은 인터벌 ID를 반환하고 이를 clearInterval
에 전달하여 인터벌을 중단할 수 있다는 사실을 기억하세요.
Solution
(이 사이트의 샌드박스와 같이) 엄격한 모드가 켜져있으면 리액트는 각 컴포넌트를 개발 모드에서 한 번 더 마운트해요. 이는 인터벌이 두 번 설정되도록 만들고 매 초마다 2씩 증가하는 이유에요.
하지만 리액트의 동작이 버그의 원인은 아니에요. 버그는 이미 코드에 있어요. 리액트의 동작은 버그가 더욱 눈에 띄게 만들어요. 실제 원인은 이 이펙트가 프로세스는 시작하지만 이 프로세스를 클린업하는 방법을 제공하지 않았다는 점이에요.
이 코드를 고치려면 setInterval
로 반환된 인터벌 ID를 저장하고 clearInterval
으로 클린업 함수를 구현하세요.
개발 모드에서 리액트는 여전히 클린업 함수가 잘 구현되었는지를 확인하기 위해 컴포넌트를 한 번 더 마운트해요. 그래서 setInterval
콜이 호출된 직후에 바로 clearInterval
이 호출되고 다시 setInterval
이 호출돼요. 프로덕션 환경에서는 한 번의 setInterval
호출만 발생해요. 사용자가 보는 동작은 이 두 경우에서 모두 같아요. 카운터는 1초당 1씩 증가해요.
4. 이펙트 안에서 페칭 수정하기
이 컴포넌트는 선택한 사람의 연혁을 보여줘요. 마운트 될 때와 person
이 변경될 때마다 비동기 함수인 fetchBio(person)
을 호출하여 연혁을 로딩해요. 이 비동기 함수는 결국 문자열로 치환되는 프로미스를 반환해요. 페칭이 끝나면 셀렉트 박스 아래에 해당 문자열을 보여주기 위한 setBio
를 호출해요.
이 코드에는 버그가 있어요. "Alice"를 선택하는 것부터 시작해볼게요. 그 다음 "Bob"을 선택한 후 바로 "Taylor"을 선택하세요. 만약 이 과정을 충분히 빠르게 진행했다면 버그를 알아챌 거예요. Taylor가 선택되었지만 아래에 "This is Bob's bio."라고 떠요.
왜 이런 일이 발생한 걸까요? 이펙트 안의 문제를 해결해보세요.
Hint
만약 이펙트가 무언가를 비동기적으로 페칭한다면 클린업을 해야해요.
Solution
버그를 발생시키려면 아래의 순서로 일이 발생해야해요.
'Bob'
을 선택하면fetchBio('Bob')
가 발생돼요.'Taylor'
을 선택하면fetchBio('Taylor')
가 발생돼요.Taylor'
을 페칭하는 것은'Bob'
을 페칭하기 전에 완료돼요.'Taylor'
렌더링의 이펙트는setBio('This is Taylor’s bio')
를 호출해요.'Bob'
을 페칭하는 것이 끝났어요.'Bob'
렌더링의 이펙트는setBio('This is Bob’s bio')
를 호출해요.
이 과정으로 인하여 Taylor가 선택되었어도 Bob의 연혁이 보여요. 두 개의 비동기 함수사 서로 "경쟁"하여 예측할 수 없는 순서로 도착하기 떄문에 이와 같은 버그를 경쟁 상태라고 해요.
경쟁 상태를 해소하려면 클린업 함수를 추가하세요.
각 렌더링의 이펙트는 ignore
변수를 갖고 있어요. 처음에 ignore
변수를 false
로 설정되어 있어요. 그러나 만약 (다른 사람을 선택했을 때처럼) 이펙트가 정리된다면 ignore
변수는 true
가 돼요. 따라서 이제 어떤 순서로 요청이 끝나든 상관이 없어요. 마지막 사람의 이펙트만 false
로 설정된 ignore
를 갖고 있기 때문에 setBio(result)
는 그 이펙트만 호출할 거예요. 과거의 이펙트는 정리되었기 때문에 if (!ignore)
는 setBio
를 호출하지 못하게 막아줘요.
'Bob'
을 선택하면fetchBio('Bob')
가 발생돼요.'Taylor'
을 선택하면fetchBio('Taylor')
가 발생되고 이전의 (Bob의) 이펙트는 정리돼요.Taylor'
을 페칭하는 것은'Bob'
을 페칭하기 전에 완료돼요.'Taylor'
렌더링의 이펙트는setBio('This is Taylor’s bio')
를 호출해요.'Bob'
을 페칭하는 것이 끝났어요.'Bob'
렌더링의 이펙트는ignore
플래그가true
로 설정되었기 때문에 아무것도 하지 않아요.
만료된 API 호출의 결과를 무시하면서 AbortController
를 사용하여 더 이상 필요하지 않는 요청을 취소할 수 있어요. 그러나 이 자체만으로는 경쟁 상태를 피하기 충분하지 않아요. 페칭 이후에 체이닝되는 추가적인 비동기 단계가 있을 수 있기 때문에 ignore
과 같은 명시적인 플래그를 사용하는 것은 이러한 문제를 해결하는 가장 신뢰도 높은 방법이에요.