[React] Effect로 동기화하기

귤티·2025년 5월 2일

front

목록 보기
8/10

일부 컴포넌트에서는 외부 시스템과 동기화해야 할 수도 있다.

  • React의 state를 기준으로 React와 상관없는 구성 요소를 제어할 때
  • 서버 연결을 설정할 때
  • 구성 요소가 화면에 나타날 때 분석 목적의 로그를 전송
    ct

Effect를 사용하면 렌더링 후 특정 코드를 실행하여 React 외부의 시스템과 컴포넌트를 동기화할 수 있다.

컴포넌트 내부의 2가지 로직 유형

  • 렌더링 코드를 주관하는 로직: 컴포넌트의 최상단에 위치, props와 state를 적절히 변형해 결과적으로 JSX를 반환
    - 렌더링 코드 로직은 순수해야 한다. 결과만 계산해야 하고, 그 외에는 아무것도 하지 말아야 한다.
  • 이벤트 핸들러: 단순한 계산 용도가 아닌 무언가를 하는 컴포넌트 내부의 중첩 함수
    - 입력 필드를 업데이트하거나
    • HTTP POST 요청을 보내거나
    • 사용자를 다른 화면으로 이동시킬 수 있다.
      이벤트 핸들러에는 특정 사용자 작업으로 인해 발생하는 "부수 효과"(부수 효과가 프로그램 상태를 변경)를 포함한다.

화면에 보일 때마다 채팅 서버에 접속해야 하는 ChatRoom 컴포넌트 예시:
서버에 접속하는 것 -> 부수 효과를 발생시키기 때문에 렌더링 중에는 할 수 없다.
클릭 한 번으로 ChatRoom이 표시되는 특정 이벤트는 하나도 없다.

Effect -> 렌더링 자체에 의해 발생하는 부수 효과를 특정하는 것
- 특정 이벤트가 아닌 렌더링에 의해 직접 발생
채팅에서 메시지를 보내는 것 -> 이벤트(사용자가 특정 버튼을 클릭함에 따라 직접적으로 발생)
서버 연결 -> Effect(컴포넌트의 표시를 주관하는 어떤 상호 작용과도 상관없이 발생해야 한다.)

Effect는 커밋이 끝난 후에 화면 업데이트가 이루어지고 나서 실행된다.

  • 이 시점이 React 컴포넌트를 외부 시스템(네트워크 또는 써드파티 라이브러리)과 동기화하기 좋은 타이밍

Effect를 작성하는 법

  1. Effect 선언: 기본적으로 Effect는 모든 commit 이후에 실행된다.
  2. Effect 의존성 지정: 대부분의 Effect는 모든 렌더링 후가 아닌 필요할 때만 다시 실행되어야 한다.
  3. 필요한 경우 클린업 함수 추가: 일부 Effect는 수행 중이던 작업을 중지, 취소 또는 정리하는 방법을 지정해야 할 수 있다. ex) 연결 - 연결 해제 필요, 구독 - 구독 취소 필요, 불러오기 - 취소 또는 무시 필요

Effect 선언하기

React에서 useEffect 훅을 import

import { useEffect } from 'react';

컴포넌트의 최상의 레벨에서 호출하고 Effect 내부에 코드를 넣음

function MyComponent() {
	useEffect(() => {
    	// 이곳의 코드는 모든 렌더링 후에 실행된다.
    });
    
    return <div />;
}

컴포넌트가 렌더링될 때마다 React -> 화면 업데이트한 다음 useEffect 내부의 코드 실행
useEffect는 화면에 렌더링이 반영될 때까지 코드 실행을 "지연"시킴

외부 시스템과 동기화하기 위해 어떻게 Effect를 사용할 수 있는가

<VideoPlayer isPlaying={isPlaying} />;

isPlaying이라는 props를 통해 재생 중인지 일시 정지 상태인지 제어

function VideoPlayer({ src, isPlaying }){
	return <video src={src} />;
}	

₩<video₩> 태그에는 isPlaying prop이 없다.
DOM 요소에서 수동으로 play() 및 pause() 메소드를 호출해야 한다.
isPlaying prop의 값을 play() 및 pause()와 같은 호출과 동기화해야 한다.

먼저 ₩<video₩> DOM 노드의 ref를 가져온다.

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

function VideoPlayer({ src, isPlaying }){
	const ref = useRef(null);
    
    if(isPlaying){
    	ref.current.play();
    } else {
    	ref.current.pause();
    }
    
    return <video ref={ref} src={src} loop playInline />;
}

export default function App(){
	const [isPlaying, setIsPlaying] = useState(false);
    
    return (
    	<>
        	<button onClick={()=>setIsPlaying(!isPlaying)}>
            	{isPlaying ? : '일시정지' : '재생'}
            </button>
            <VideoPlayer
            	isPlaying={isPlaying}
                src="https://"
            />
        </>
    );
}

-> 이 코드는 올바르지 않다.
렌더링 중에 DOM 노드를 조작하려고 시도하기 때문이다.
처음으로 VideoPlayer가 호출될 때 해당 DOM이 아직 존재하지 않는다.
React는 컴포넌트가 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 src={src} ref={ref} loop playInline />;
}

DOM 업데이트를 Effect로 감싸면 React가 화면을 업데이트한 다음에 Effect가 실행된다.

VideoPlayer 컴포넌트가 렌더링 될 때(처음 호출하거나 다시 렌더링 할 때)
React는 화면을 업데이트하여 video 태그가 올바른 속성과 함께 DOM에 있는지 확인
그런 다음 React는 Effect를 실행
마지막으로 Effect에서는 isPlaying 값에 따라 play() 또는 pause()를 호출

이 예시에서 React 상태와 동기화된 외부 시스템 -> 브라우저 미디어 API
React가 아닌 레거시 코드(ex: JQuery 플러그인)를 선언적인 React 컴포넌트로 감싸는 데에 사용 가능

=> 예시는 매우 단순화된 것이다.
play()를 호출하는 것이 실패될 수도 있다.

주의

기본적으로 Effect는 모든 렌더링 후에 실행된다.
이거 때문에 다음 코드는 무한 루프가 생길 것이다.

const [count, setCount] = useState(0);

useEffect(() => {
	setCount(count + 1);
});

state를 설정하면 렌더링이 트리거된다.
Effect 안에서 상태를 설정하는 것 -> Effect가 실행되고 상태가 설정되면 재렌더링이 발생, Effect가 다시 실행되고 상태가 설정되면 또 다른 재렌더링 발생

Effect는 컴포넌트를 외부 시스템과 동기화하는 데 사용되기에 외부 시스템잉 없고 다른 상태에 기반하여 상태를 조정하려는 경우엔 Effect가 필요하지 않을 수 있다.

2단계 Effect의 의존성 지정하기

Effect는 기본적으로 모든 렌더링 후에 실행된다.

  • 때때로 느릴 수 있다. 외부 시스템과 동기화하는 것이 항상 바로 이루어지지 않아서 필요하지 않을 때는 실행을 건너뛸 수도 있다.
  • 때때로 잘못될 수 있다.

예시 - console.log 호출과 부모 컴포넌트 상태를 업데이트하는 텍스트 입력

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

function VideoPlayer({ src, isPlaying }){
	const ref = useRef(null);
    
    useEffect(() => {
    	if(isPlaying){
        	console.log('video play() call');
            ref.current.play();
        } else {
        	console.log('video pause() call');
            ref.current.pause();
        }
    });
    
    return <video ref={ref} src={src} loop playInline />;
}

export default function App() {
	const [isPlaying, setIsPlaying] = useState(false);
    const [text, setText] = useState("");
    return (
    	<>
        	<input value={text} onChange={e => setText(e.target.value)} />
            <button onClick={() => setIsPlaying(!isPlaying)}>
            	{isPlaying ? '일시 정지' : '재생'}
            </button>
            <VideoPlayer
            	isPlaying={isPlaying{
                src="https://"
            />
        </>
    );
}

input에 text를 입력하면 계속 Effect가 실행된다.

React에게 Effect를 불필요하게 다시 실행되지 않도록 지시하려면 useEffect 호출의 두 번째 인자로 의존성(dependencies) 배열을 지정한다.

useEffect(()=> {
	// ...
}, []);

빈 배열을 추가한다.
'isPlyaing'에 대한 의존성이 누락되었다는 오류가 뜰 것이다.

Effect 내부의 코드가 어떤 작업을 수행할지 결정하기 위해 isPlayaing prop에 의존하지만 이 의존성이 명시적으로 선언되지 않았다.

-> 의존성 배열에 isPlaying을 추가해야 한다.

useEffect(()=>{
	if(isPlaying){
    
    } else {
    
    }
}, [isPlaying]);

의존성 배열로 [isPlaying]을 지정하면 React에게 이전 렌더링 중에 isPlaying이 이전과 동일하다면 Effect를 다시 실행하지 않도록 해야 한다고 알려준다.

이제 입력란에 텍스트를 입력하면 Effect가 다시 실행되지 않고, 재생/일시정지 버튼을 누르면 Effect가 실행된다.

의존성 배열에는 여러 개의 종속성을 포함 가능하다.
React -> 지정한 모든 종속성이 이전 렌더링의 그것과 정확히 동일한 값을 가진 경우에만 Effect를 다시 실행하지 않는다.
React는 Object.js 비교를 사용하여 종속성 값을 비교한다.

의존성을 선택할 수 없다.
의존성 배열에 지정한 종속성이 Effect 내부의 코드를 기반으로 React가 기대하는 것과 일치하지 않으면 린트 에러 발생

주의

의존성 배열이 없는 경우와 빈 의존성 배열 []이 있는 경우 동작이 다르다.

useEffect(() => {
 // 모든 렌더링 후에 실행됨
});

useEffect(()=> {
	// 마운트될 때만 실행됨(컴포넌트가 나타날 때)
}, []);

useEffect(() => {
	// 마운트될 때 실행되며, *또한* 렌더링 이후에 a, b 중 하나라도 변경된 경우에도 실행됨
}, [a, b]);

ref를 의존성 배열에서 생략해도 되는 이유

ref 객체가 안정된 식별성(stable identity)를 가지기 때문.
React는 동일한 useRef 호출에서 항상 같은 객체를 얻을 수 있음을 보장함.
이 객체는 절대 변경되지 않기 때문에 자체적으로 Effect를 다시 실행시키지 않는다.
따라서 ref는 의존성 배열에 포함하든 말든 상관 없다.

useState로 반환되는 set 함수들도 안정된 식별성을 가지기 때문에 의존성에서 생략되는 것을 볼 수 있다.

안정된 식별성을 가진 의존성을 생략하는 것은 린터가 해당 객체가 안정적임을 알 수 있는 경우에만 작동한다.
예시)
ref가 부모 컴포넌트에 전달되었다면, 의존성 배열에 명시해야 함.
-> 부모 컴포넌트가 항상 동일한 ref를 전달하는지 또는 여러 ref 중 하나를 조건부로 전달하는지 알 수 없기 때문이다.

3단계: 필요하다면 클린업 추가

예시) 사용자에게 표시될 때 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트
createConnection() API가 주어짐
-> connect(), disconnect() 메소드를 가진 객체를 반환

사용자에게 표시되는 동안 컴포넌트가 채팅 서버와의 연결을 유지하려면:

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

매번 재렌더링 후에 채팅 서버에 연결하는 것은 느리므로 의존성 배열 추가:

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

Effect 내부의 코드는 어떠한 props나 상태도 사용하지 않으므로 의존성 배열은 빈 배열이다.
-> 컴포넌트가 마운트될 때만 실행하도록 함 -> 화면에 처음으로 나타날 때에만 실행되게 함

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>채팅에 오신걸 환영합니다!</h1>;
}

-> console에 연결 중...이 2번 찍힌다.

ChatRoom 컴포넌트가 여러 화면으로 구성된 큰 앱의 일부라고 가정
사용자가 ChatRoom 페이지에서 여정 시작
컴포넌트가 마운트되고 connection.connect()를 호출
그런 다음 사용자가 다른 화면으로 이동 -> ChatRoom 컴포넌트 마운트 해제
사용자가 뒤로 가기 버튼 클릭 -> ChatRoom 다시 마운트

  • 두 번째 연결이 설정되지만 첫 번째 연결은 종료되지 않았다.
  • 사용자가 앱을 탐색하는 동안 연결이 종료되지 않고 계속 쌓일 것

=> 문제를 빠르게 파악할 수 있도록 React는 개발 모드에서 초기 마운트 후 모든 컴포넌트를 한 번 다시 마운트
한다.

문제를 해결하려면:

  • Effect에서 클린업 함수를 반환
useEffect(()=>{
	const connection = createConnection();
    connection.connect();
    return () => {
    	connection.disconnect();
    };
}, [])'

React는 Effect가 다시 실행되기 전마다 클린업 함수를 호출
컴포넌트가 마운트 해제될 때도 마지막으로 호출

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

export default function ChatRoom() {
	useEffect(() => {
    	const connection = createConnection();
        connection.connect();
        return () => {
        	connection.disconnect();
        }
    }, []);
    return <h1>채팅에 오신 걸 환영합니다!</h1>;
}

console

  • 연결 중...
  • 연결 해제됨
  • 연결 중...
    -> 개발 모드에서 올바른 동작
    컴포넌트를 다시 마운트함으로써 React는 사용자가 다른 부분을 탐색하고 돌아와도 코드가 깨지지 않을 것을 확인

개발 중에는 연결/해제 호출이 하나 더 있다 -> React가 개발 중에 코드를 검사하여 버그를 찾는 것
정상적인 동장이다.

배포 환경에서는 연결 중...이 한 번만 출력된다.
컴포넌트를 다시 마운트하는 것은 개발 중에만 발생하며 클린업이 필요한 Effect를 찾아주는 데 도움을 준다.

개발 중에 Effect가 두 번 실행되는 경우를 다루는 방법

어떻게 Effect가 다시 마운트된 후에도 작동하도록 고칠 것인가
-> 정답: 클린업 함수를 구현하는 것

클린업 함수 - Effect가 수행하던 작업을 중단하거나 되돌리는 역할
기본 원칙: 사용자가 Effect가 한 번 실행되는 것과 설정->클린업->설정 순서 간에 차이를 느끼지 못해야 한다.

Effect가 두 번 실행되는 것을 막기 위해 ref를 사용하지 말라

ref를 사용해 Effect가 한 번만 실행되도록 하는 것 금지

const connectionRef = useRef(null);

useEffect(()=>{
	if(!connectionRef.current){
    	connectionRef.current = createConnection();
        connectionRef.curent.connect();
    }
}, []);

이렇게 하면 개발 모드에서 연결 중...이 한 번만 보이지만 버그가 수정된 건 아니다.
사용자가 다른 곳에 가도 연결이 끊어지지 않고
다시 사용자가 돌아왔을 때 새로운 연결이 생성 -> 연결이 계속 쌓임

React로 작성되지 않은 위젯 제어하기

종종 React로 작성되지 않은 UI 위젯을 추가해야 할 때가 있다.
ex) 페이지에 지도 컴포넌트 추가
지도 컴포넌트의 setZoomLevel() 메소드 -> zoomLevel state와 동기화하려 할 것

useEffect(() => {
	const map = mapRef.current;
    map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

이 경우에는 클린업이 필요 없다.
개발 모드에서 React는 Effect를 두 번 호출하지만 동일한 값(zoomLevel 값이 변하지 않음)을 가지고 setZoomLevel을 두 번 호출하는 건 문제가 되지 않는다.

  • Effect가 두 번 호출되어도 같은 값이면 문제 없음
    약간 느릴 수 있으나 제품 환경에서 불필요하게 다시 마운트되지 않기 때문에 문제 안 됨

일부 API는 연속 두 번 호출을 허용하지 않는다. -> ex) dialog 요소의 showModal 메소드는 두 번 호출하면 예외를 던짐

useEffect(() => {
	const dialog = dialogRef.current;
    
    dialog.showModal();
    return () => dialog.close();
}, []);

개발 중에는 Effect가 showModal()을 호출한 다음 즉시 close()를 호출하고 다시 showModal()을 호출
=> 제품 환경에서 볼 수 있는 것과 동일

이벤트 구독하기

만약 Effect가 어떤 것을 구독한다면, 클린업 함수에서 구독을 해지해야 한다.

useEffect(() => {
	function handleScroll(e) {
    	console.log(window.scrollX, window.scrollY);
    }
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
}, []);

개발 중에는 Effect가 addEventListener()를 호출한 다음 즉시 removeEventListener()를 호출
그 다음 동일한 핸들러로 addEventListener()를 호출함.
-> 한 번에 하나의 활성 구독만 있게 됨
제품 환경에서 한 번 addEventListener()를 호출하는 것과 동일한 동작을 가짐

애니메이션 트리거

Effect가 어떤 요소를 애니메이션으로 표시하는 경우
-> 클린업 함수에서 애니메이션을 초기 값으로 재설정해야 함

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로 직접 설정하는 것과 동일한 동작
tweening을 지원하는 서드파티 애니메이션 라이브러리를 사용하는 경우
-> 클린업 함수에서 타임라인을 초기 상태로 재설정해야 함

데이터 패칭

Effect가 어떤 데이터를 가져온다면, 클린업 함수에서는 fetch를 중단하거나 결과를 무시해야 함

useEffect(() => {
	let ignore = false;
    
    async function startFetching() {
    	const json = await fetchTodos(userId);
        if(!ignore) {
        	setTodos(json);
        }
    }
    
    startFetching();
    
    return () => {
    	ignore = true;
    };
}, [userId]);
  • userId가 바뀔 때마나 할 일 목록을 가져오고 setTodos로 화면에 반영한다.
  • 요청은 바로 안 끝나고 시간이 걸리기 때문에 그 전에 userId가 바뀌면? 이전 요청 결과가 나중에 도착할 수도 있다.

🧨 문제: race condition
예를 들어 이런 일이 발생할 수 있어요:
userId = "alice"일 때 effect 실행 → fetchTodos("alice") 요청 보냄

요청이 끝나기도 전에 → userId = "bob"로 바뀜

React가 effect 클린업 실행 → ignore = true 설정

fetchTodos("alice") 결과가 늦게 도착함

클린업이 없다면 → setTodos(alice의 데이터)가 호출됨! 😱

이러면 화면에 "bob의 UI인데, alice의 데이터"가 보이는 이상한 상황이 됨

이미 발생한 네트워크 요청을 실행 취소할 수는 없다.
클린업 함수는 더이상 관련이 없는 패치가 애플리케이션에 계속 영향을 미치지 않도록 해야 한다.
userId가 Alice에서 Bob으로 변경되면 클린업은 Bob 이후에 도착하더라도 Alice 응답을 무시하도록 보장한다.

개발 중에는 네트워크 탭에 두 개의 패치가 표시됨
그러나 문제 없다.
첫 번째 Effect는 즉시 클린업되어 ignore 변수의 복사본이 true로 설정된다.
-> 추가 요청이 있더라도 if(!ignore) 검사 덕분에 state에 영향을 미치지 않는다.

제품 환경에서는 하나의 요청만 있을 것
두 번째 요청이 문제가 되면 가장 좋은 방법은 중복 요청을 제거하고 컴포넌트 간에 응답을 캐시하는 솔루션을 사용하는 것

funcion TodoList() {
	const todos = useSomeDataLibrary('/api/user/${userId}/todos');
	// ...
}

-> 개발 환경 개선, 애플리케이션 반응 속도 향상
ex) 사용자가 뒤로 가기 버튼을 눌렀을 때 데이터를 다시 로드하는 것을 기다릴 필요가 없다. (데이터가 캐시되기 때문)

Effect에서 데이터를 가져오는 좋은 대안

단점

  • Effect는 서버에서 실행되지 않는다. - 초기 서버 렌더링된 HTML은 데이터가 없는 로딩 상태만 포함하게 된다.클라이언트 컴퓨터는 모든 JavaScript를 다운하고 앱을 렌더링해야만 데이터를 로드해야 한다는 것을 알게될 것
  • Effect 안에서 직접 가져오면 네트워크 폭포를 쉽게 만들 수 있다. - 부모 컴포넌트를 렌더링하면 일부 데이터를 가져오고 자식 컴포넌트를 렌더링한 다음 데이터를 가져오기 시작한다. 네트워크가 빠르지 않으면 모든 데이터를 병렬로 가져오는 것보다 느리다.
    - Effect 안에서 직접 가져오는 것은 일반적으로 데이터를 미리 로드하거나 캐시하지 않음을 의미 - 컴포넌트가 마운트 해제되고 다시 마운트되면 데이터를 다시 가져와야 한다.
  • 불편하다.

다음 방식을 권장

  • 프레임워크를 사용하는 경우 해당 프레임워크의 내장 데이터 패칭 매커니즘 사용
  • 그렇지 않은 경우 클라이언트 측 캐시를 사용하거나 구축하는 것을 고려 -> React Quer, useSWR, React Router 등. 직접 솔루션 구축 가능, Effect를 내부적으로 사용하면서 요청 중복을 제거하고 응답을 캐시하고 네트워크 폭포를 피하는 로직을 추가할 것(데이터를 사전에 로드하거나 데이터 요구 사항을 라우트)

분석 보내기

페이지 방문 시 분석 이벤트를 보내는 코드:

useEffect(() => {
	logVisit(url); // POST 요청을 보냄
}, [url]);

개발 환경에서는 logVisit가 각 URL에 대해 두 번 호출될 것
-> 이 코드를 그대로 유지하는 것을 권장
컴포넌트는 파일을 저자할 때마다 재마운트되므로 개발 환경에서는 추가적인 방문 기록을 로그에 남기게 된다.

제품 환경에서는 중복된 방문 로그가 없을 것이다
보내는 분석 이벤트를 디버깅하려면 앱을 스테이징 환경에 배포하거나 Strinct Mode를 일시적으로 사용 중지하여 개발 환경 전용의 재마운팅 검사를 수행할 수 있다. 또한 Effect 대신 라우트 변경 이벤트 핸들러에서 분석을 보낼 수도 있다.

Effect가 아닌 경우

애플리케이션 초기화

  • 일부 로직은 애플리케이션 시작 시에 한 번만 실행되어야 함
    이러한 로직은 컴포넌트 외부에 배치할 수 있다.
if(typeof window !== 'undefined') {// 브라우저에서 실행 중인지 확인
	checkAuthToken();
    loadDataFromLacalStorage();
}

function App() {

}

컴포넌트 외부에서 해당 로직을 실행하면 해당 로직은 브라우저가 페이지를 로드한 후 한 번만 실행됨이 보장된다.

제품 구입하기

제품을 구매하는 POST 요청을 보내는 Effect가 있다고 가정

useEffect(() => {
	fetch('/api/buy', { method: 'POST' });
}, []);
  • 이 Effect는 개발 환경에서 두 번 실행되며 코드에 문제가 드러남
    사용자는 페이지를 방문할 때 제품을 구매하려는 게 아니라 구매 버튼을 클릭할 때 제품을 구매하고 싶은 것
    구매 -> 렌더링에 의해 발생하는 것이 아니라 특정 상호 작용에 의해 발생

Effect를 삭제하고 /api/buy 요청을 But 버튼의 이벤트 핸들러로 이동

function handleClick() {
	fetch('/api/buy', { method: 'POST' });
}

만약 컴포넌트를 다시 마운트했을 때 애플리케이션의 로직이 깨진다면 기존에 존재하던 버그가 드러난 것

예시

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 스케줄 로그 "' + text);
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 취소 로그 "' + text);
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        컴포넌트 {show ? '마운트 해제' : '마운트'}
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

처음에는 스케줄 로그 a, 취소 로그 a, 스케줄 로그 a 라는 세 가지 로그가 뜸
몇 초 후에 a 라는 로그가 나타날 것
-> 추가된 스케줄/취소 쌍은 React가 컴포넌트를 개발 중에 한 번 다시 마운트하여 정리를 제대로 구현했는지 확인하기 때문

입력란을 abc로수정:
빠르게 입력하면 스케줄 ab 로그 바로 뒤에 취소 ab 로그와 스케줄 abc 로그가 나타날 것
React는 항상 이전 렌더의 Effect를 다음 렌더의 Effect보다 먼저 정리함
-> 빠르게 입력하더라도 한 번에 최대 하나의 타임아웃만 예약됨

입력란에 무언가 입력한 다음 컴포넌트 마운트 해제를 누르면 마운트 해제가 마지막 렌더의 Effect를 정리함을 볼 수 있다.
-> 타임아웃이 실행되기 전에 마지막 타임아웃이 취소된다.

정리함수를 주석 처리하여 타임아웃이 취소되지 않게 하면:
abcde를 빠르게 입력했을 때 a, ab, abc, abcd, abcde라는 일련의 로그를 볼 수 있음
각 Effect는 해당 렌더의 text 값을 캡처함
text='ab' 렌더의 Effect에서는 항상 ab를 볼 것이다.
-> 각 렌더의 Effect는 서로 격리되어 있음

more... -> 클로저

각각의 렌더링은 고유한 Effect를 갖는다

useEffect를 렌더링 결과물에 부착하는 것으로 생각 가능

export default function ChatRoom({ roomId })[
	useEffect(() => {
    	const connection = createConnection(roomId);
        connection.connect();
        return () => conenctoin.disconnect();
    }, [roomId]);
    
    return <h1>welcome to {roomId}</h1>;
}

초기 렌더링

  • 사용자가 을 방문
    roomId를 general로 멘탈모델 위에서 대체해봄
return <h1>Welcome to general</h1>;

Effect 또한 렌더링 결과물의 일부이다.
첫 번째 렌더링의 Effect:

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

React는 이 Effect를 실행하며 general 채팅방에 연결함

같은 의존성 사이에서의 재렌더링

  • ChatRoom roomId=general이 다시 렌더링된다고 가정:
    JSX 결과물은 동일
return <h1>Welcome to general</h1>;

React는 렌더링 출력이 변경되지 않았기에 DOM을 업데이트하지 않는다.

두 번째 렌더링에서의 ['general']을 첫 번째 렌더링의 ['general']과 비교
-> 모든 의존성이 동일하므로 React는 두 번째 렌더링에서의 Effectf를 무시함
해당 Effect는 호출되지 않는다.

다른 의존성으로 재렌더링

  • ChatRoom roomId="travel"을 탐색
    컴포넌트가 다른 JSX를 반환함
return <h1>Welcome to travel</h1>;

React는 DOM을 업데이트하여 변경함

세 번째 렌더링에서의 Effect:

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

세 번째 렌더링에서의 ['travel']을 두 번째 렌더링의 ['general']과 비교

  • 하나의 의존성이 다름 -> Effect는 건너뛸 수 없다.

React는 세 번째 렌더링의 Effect를 적용하기 전에 먼저 실행된 Effect를 정리해야 한다.
두 번째 렌더링의 Effect는 건너뛰어졌기 때문에 첫 번째 렌더링의 Effect를 정리해야 한다.
처음 렌더링되었을 때 스크롤하면, createConnection('general')로 생성된 연결에 대해 disconnect()를 호출하는 것을 볼 수 있다. -> 앱은 'general' 채팅방과의 연결이 해제된다.

그 후 세 번째 렌더링의 Effect를 실행해 travel 채팅방에 연결한다.

마운트 해제
마지막으로 사용자가 다른 페이지로 이동하게 되어 ChatRoom 컴포넌트가 마운트 해제 된다.
마지막 Effect(세 번째 렌더링)의 클린업 함수를 실행한다.

요약

  • 이벤트와 달리 Effect는 특정 상호작용이 아닌 렌더링 자체에 의해 발생한다.
  • Effect를 사용하면 컴포넌트를 외부 시스템(타사 API, 네트워크 등)과 동기화할 수 있다.
  • 기본적으로 Effect는 모든 렌더링(초기 렌더링 포함) 후에 실행된다.
  • React는 모든 의존성이 마지막 렌더링과 동일한 값을 가지면 Effect를 건너뛴다.
  • 의존성은 Effect 내부의 코드에 의해 결정되므로 선택할 수 없다.
  • 빈 의존성 배열 []은 컴포넌트 마운팅을 의미한다.
  • Strict Mode에서 리액트는 컴포넌트를 두 번 마운트한다(개발 환경) -> Efffect의 스트레스 테스트 용도
  • Effect가 다시 마운트로 인해 중단된 경우 클린업 함수를 구현해야 함
  • React는 Effect가 다음에 실행되기 전에 정리 함수를 호출하며, 마운트 해제 중에도 호출한다.

챌린지

마운트 시 input 필드에 포커스하기

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }){
	const ref = useRef(null);
    
    useEffect(() => {
    	ref.current.focus();
    }, []);
    
    return (
    	<input
        	ref={ref}
            value={value}
            onChange={onChange}
        />
    );
}

렌더링 중에 ref.current.focus() 호출은 부적절 -> 부수효과이기 때문
=> useEffect로 선언(특정 상호작용이 아니라 컴포넌트가 나타나는 것에 의해 발생되기 때문)
[] 의존성 -> 렌더링 후 매번 실행되는 것이 아니라 마운트 시에만 실행되도록 함

조건부로 input 필드에 포커스하기

폼에서 두 개의 MyInput 컴포넌트를 렌더링
form 보기를 누르면 두 번째 필드가 자동으로 포커스됨
-> 두 MyInput 컴포넌트 모두 내부의 필드에 포커스를 주려고 하기 때문에 두 개의 입력 필드에 연속해서 focus를 호출하면 항상 마지막 호출이 승리하게 됨

첫 번째 필드에 포커스를 주려면 첫 번째 MyInput 컴포넌트가 true로 설정된 shouldFocus prop을 받도록 변경해야 함
-> MyInput이 받은 prop이 true일 때만 focus()가 호출되도록 변경

import { useEffect, useRef } from 'react';

export default function MyInput({ shoouldFocus, value, onChange }){
	const ref = useRef(null);
    
    useEffect(() => {
    	if(shouldFocus)
        	ref.current.focus();
    }, [shouldFocus]);
    
    return (
    	<input
        	ref={ref}
            value={value}
            onChange={onChange}
        />
    );
}

두 번 실행되는 interval 고치기

Counter 컴포넌트 -> 매 초마다 증가하는 카운터
컴포넌트가 마운트될 때 setInterval을 호출
이로 인해 onTick 함수가 매 초마다 실행됨
onTick 함수 -> 카운터를 증가시킴
하지만 1초마다 한 번씩 증가하는 대신 두 번씩 증가함
원인을 찾아 수정하기

힌트 setInterval은 interval ID를 반환한다. 이를 clearInterval 함수에 전달하여 Interval을 중지

import { useEffect, useRef } from 'react';

export default function Counter() {
	const [count, setCount] = useState(0);
    
    useEffect(() => {
    	function onTick(){
        	setCount(c => c + 1);
        }
        
        const intervalId = setInterval(onTick, 1000);
        return () => clearInterval(intervalId);
    }, []);
    
    return <h1>{count}</h1>
}

Effect 내부에서의 잘못된 데이터 패치 고치기

select 태그로 선택한 사람의 일대기를 보여주는 컴포넌트
-> 선택된 person이 변경될 때마다, 또한 마운트될 때마다 비동기 함수 fetchBio(person) 함수를 호출하여 일대기를 불러옴
이 비동기 함수는 Promise를 반환하며, 이 Promise는 결국 문자열로 resolve됨
불러오기가 완료되면 setBio를 호출하여 해당 문자열을 select의 option으로 표시

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

export default function Page() {
	const [person, setPerson] = useState('Alice');
    const [bio, setBio] = useState(null);
    
    useEffect(() => {
    	let ignore = false;
        
        setBio(null);
        fetchBio(person).then(result => {
        	if(!ignore) {
            	setBio(result);
            }
        });
        
        return () => { ignore = true; }
    }, [person]);
    
    return (
    	<>
        	<select value={person} onChange={e => {
            	setPerson(e.target.value);
            }}>
            	<option value="Alice">Alice</option>
                <option value="Bob">Bob</option>
                <option value="Taylor">Taylor</option>
            </select>
            <hr />
            <p><i>{bio ?? 'Loading...'}</i></p>
        </>
    );
}

해설

  • Bob을 선택하면 fetchBio(bob)이 트리거
  • Taylor을 선택하면 fetchBio(Taylor)가 트리거
  • Taylor의 일대기를 가져오는 작업이 Bob의 일대기를 가져오는 작업보다 먼저 완료됨
  • Taylor 렌더링의 Effect가 setBio를 호출
  • Bob 일대기를 가져오는 작업이 완료됨
  • Bob 렌더링의 Effect가 setBio를 호출

-> Taylor가 선택되어도 Bob의 일대기가 표시된다.
경쟁 조건 발생
클린업 함수가 필요하다

각 렌더링의 Effect는 자체 ignore 변수를 가지고 있다.
처음에 ignore 변수는 false로 설정된다.
Effect가 클린업되면 해당 Effect의 ignore 변수는 true로 설정된다.
마지막 사람의 Effect만 ignore가 false로 설정되어 setBio(result)를 호출한다.

  • 이전 Effect는 정리되었으므로 If(!ignore) 검사가 setBio 호출을 방지한다.
  • 더 이상 필요하지 않은 요청을 취소하기 위해 AboutController를 사용할 수도 있다.
profile
취준 진입

0개의 댓글