"side-effect"란 함수가 실행되면서 "함수 외부에 존재하는 값이나 상태에 의존하거나 변경"하는 작업을 말합니다.
즉, 함수 내부에서 함수 외부와 상호작용하는 코드들을 우리는 side-effect라고 말합니다. side-effect는 함수를 읽기 어렵게 만들고 결과를 예측하는데 어려움을 줍니다.
side-effect가 없는 함수는 인수로 입력값을 전달받아 함수 내부 로직이 실행되고 결과를 반환하는데 그 반환값은 언제나 "예측 가능하며 일관된 결과값"을 반환합니다. 이는 로직이 쉽게 읽히며 유지보수하기도 쉬워집니다.
그러므로 우리는 side-effect를 함수와 분리하여 함수 내부에서는 외부와 상호작용하는 로직이 없도록 작성하며 이러한 함수를 순수 함수라고 합니다.
우리가 직면할 수 있는 side-effect로는 함수 내부에서 Ajax
, setTimeout
, setInterval
, DOM 조작
등 함수 내부를 벗어나 "함수 외부와 상호작용"하는 동작들이 side effect가 됩니다.
다시 말해서, 함수 외부와 상호작용하는 모든 작업들을 side effect라고 부르며, side effect가 없는 함수를 순수 함수라고 합니다.
side effect가 없는 함수는 "외부의 상태와 상호작용하지 않으며 항상 전달받는 입력에만 의존하여 출력"하는 함수를 우리는 "순수 함수"이라고 하며, 순수 함수로 프로그램을 작성하는 방식을 함수형 프로그래밍이라고 합니다.
function addResult(a, b) {
const result = a + b; // -> Not Side Effect!
return result;
}
addResult 함수는 Side Effect가 없는 순수 함수입니다. 이 함수의 역할은 입력으로 전달받은 값을 더하여 반환하는 역할만을 하며, 외부와 상호작용 하는 동작 또한 없습니다.
리액트의 컴포넌트 함수는 "순수 함수"로 작성해야 합니다. 즉, 동일한 입력(props)을 전달하면 언제나 "일관된 출력값(리액트 엘리먼트)을 반환"해야 하며, 컴포넌트 함수 "외부와는 상호작용을 하지 않도록" 작성해야 합니다.
컴포넌트 함수 내 대표적인 side effect로는 서버에게 비동기로 데이터를 요청하는 작업, 외부 객체인 DOM을 직접 조작하는 작업, 외부 환경인 브라우저의 localStroage와 상호작용하는 작업, 타이머 함수인 setTimeout, setInterval 등 존재합니다.
이러한 side effect를 컴포넌트 함수 내부에 작성한다면 컴포넌트의 상태가 변경될 때마다 함수 컴포넌트가 재평가되면서 side effect도 매번 실행 됩니다. 매번 실행되는 side effect가 렌더링 과정에서 실행되기 때문에 렌더링 자체에 영향을 주어 성능면에서 악영향을 줄 수 있습니다.
리액트에서는 이러한 부수 효과(side-effect)를 처리해주는 도구로 useEffect
훅을 제공합니다. useEffect
는 리액트 컴포넌트가 "렌더링된 이후" 특정 상황일 때 특정 작업을 수행하도록 설정할 수 있는 훅입니다.
즉, useEffect
는 side effect를 렌더링 이후에 실행시키며 수행되는 시점에는 이미 DOM이 업데이트 되었음을 보장합니다.
다르게 해석하면, 더이상 side effect는 렌더링에 영향을 주지 않도록 설계되었습니다.
import { useEffect } from 'react';
const MyComponent = () => {
useEffect(effectFn, [...deps]);
,,,,
};
export default MyComponent;
useEffect
훅은 두 개의 인수를 전달받아 실행됩니다.
effectFn
: "컴포넌트가 렌더링된 이후 호출될 함수"를 전달합니다. 기본적으로 이 콜백 함수는 컴포넌트가 평가되어 렌더링이된 이후에 실행되는 함수입니다. 우리는 effectFn 내부에서 side-effect를 처리합니다.
[...dependencies]
: 두 번째 인수로는 의존성 배열을 전달합니다. 해당 배열에는 컴포넌트 내부에서 선언된 식별자중 effectFn 함수 내부에서 사용중인 모든 식별자를 요소로 추가해주어야 합니다.
컴포넌트가 재평가될 때 배열의 요소가 이전과 하나라도 변경되었다면 컴포넌트가 리렌더링된 이후에 effectFn을 호출하게 됩니다.
이때 요소로 상태 변경 함수, 전역 변수(함수), 외부 API 호출 등은 추가하지 않아도 됩니다. 이들은 변경되지 않음을 보장하기 때문입니다.
즉, effectFn은 컴포넌트가 초기에 렌더링된 이후 무조건 한 번 호출되고, 컴포넌트가 재평가될 때 dependencies 배열의 요소가 변경된 경우에만 리렌더링된 이후에 호출됩니다. 만약 dependencies 배열의 요소가 변경되지 않은 경우에는 effectFn을 실행하지 않습니다.
useEffect
훅은 다음과 같이 세 가지 방식으로 동작이 가능합니다.
인수로 "콜백 함수"만 전달한 경우, 처음 컴포넌트가 렌더링된 이후 무조건 한 번 호출되고, 이후 상태가 변경되어 컴포넌트가 재평가되고 컴포넌트가 "리렌더링된 이후마다 호출"됩니다.
이때 주의할 점으로 effectFn 내부에서 객체 타입의 상태값을 변경하는 경우에 컴포넌트 리렌더링과 effectFn이 반복해서 실행되는 무한 루프에 빠지게 되므로 주의해야 합니다. 이 경우 두 번째 인수로 빈 배열을 추가하여 무한 루프를 해결할 수 있습니다.
인수로 "콜백 함수"와 "빈 배열"을 전달한 경우 처음 컴포넌트가 렌더링된 이후 무조건 한 번 호출이되고 이후에는 호출되지 않습니다. 즉, 단 한 번만 호출됩니다.
인수로 "콜백 함수"와 "요소가 존재하는 배열"을 전달한 경우 처음 컴포넌트가 렌더링된 이후 무조건 한 번 호출되고 이후 상태가 변경되어 컴포넌트가 재평가될 때 두 번째 인수로 전달한 배열의 요소가 변경된 경우에만 리렌더링된 이후에 effectFn을 호출합니다.
이때 주의할 점으로 dependencies 배열의 요소로 객체 타입의 값이 존재하는 경우 컴포넌트가 재평가될 때마다 참조형 값은 매번 새롭게 생성되기 때문에 effectFn 함수가 리렌더링될 때마다 실행된다는 점에 주의해야 합니다.
import { useState } from 'react';
const MyComponent = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [enteredInput, setEenteredInput] = useState('');
// 브라우저의 localStroage에서 로그 정보 확인
const storedUserLoggedInfo = localStroage.getItem('isLoggedIn');
if (stroageUserLoggedInfo === '1') {
setIsLoggedIn(true);
}
// submit 이벤트 발생시 호출될 이벤트 핸들러
// 내부에서는 localStroage에 로그인 되었다는 정보를 입력
const submitHandler = event => {
event.preventDefault();
localStroage.setItem('isLoggedIn', '1');
setIsLoggedIn(true);
};
const inputChangeHandler = event => {
setEnteredInput(event.target.value);
}
return (
<form onSumbit={submitHandler}>
<input type="text" onChange={inputChangeHandler}/>
</form>
);
}
위 코드에서는 form 요소에 submit 이벤트가 발생하면 submitHandler가 호출됩니다. submitHandler 내부에서는 localStroage라는 브라우저 내장 스토리지에서 로그인 되었다는 정보를 localStraoge에 저장합니다.
이후 MyComponent가 재평가된다면 localStorage에서 로그인이 되었는지를 나타내는 isLoggedIn의 값을 확인하여 1이라면 setLoggedIn 상태를 true로, 0이라면 false로 업데이트합니다.
이 코드는 enteredInput이나 setIsLoggedIn 상태가 변경될 때마다 매번 localStroage의 로그 정보를 확인합니다. 이는 렌더링과는 무관한 동작이며 불필요하게 많이 실행됩니다. 또한 외부 환경인 브라우저의 localStroage와 상호작용하는 것은 "렌더링과 관련이 없는 side effect"에 해당합니다. 해당 side effect를 useEffect 훅으로 처리해보겠습니다.
import { useState } from 'react';
const MyComponent = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [enteredInput, setEenteredInput] = useState('');
// 컴포넌트가 첫 렌더링된 이후에 한 번만 호출
useEffect(() => {
const storageUserLoggedInfo = localStroage.getItem('isLoggedIn');
if (storageUserLoggedInfo === '1') {
setIsLoggedIn(true);
}
}, []);
// submit 이벤트 발생시 호출될 이벤트 핸들러
// 내부에서는 localStroage에 로그인 되었다는 정보를 입력
const submitHandler = event => {
event.preventDefault();
localStroage.setItem('isLoggedIn', '1');
setIsLoggedIn(true);
};
const inputChangeHandler = event => {
setEnteredInput(event.target.value);
}
return (
<form onSumbit={submitHandler}>
<input type="text" onChange={inputChangeHandler}/>
</form>
);
}
이제 MyComponent 컴포넌트는 렌더링과 관련된 로직만을 수행하며, 렌더링이 끝난 뒤에 로그인 정보를 localStorage에서 검사합니다. 이후 컴포넌트가 리렌더링되더라도 localStorage에서 로그인 정보를 확인하는 side-effect는 실행되지 않습니다.
useEffect
훅을 사용할 때 주의할 점으로 두 번째 인수로 전달하는 배열(dependencies)에는 컴포넌트 내부에서 선언/정의되었으며, effectFn 내부에서 사용되는 "모든 것들을 추가"해주어야 합니다. 하지만 몇 가지 예외가 존재합니다.
"상태 변경 함수"를 요소로 추가할 필요는 없습니다. 리액트는 해당 함수를 절대 변경되지 않는다는 것을 보장하기 때문입니다.
"내장 API 또는 함수"를 추가할 필요는 없습니다. fetch()나 localStorage 같은 것들을 말합니다. 이들은 컴포넌트 내부에서 선언/정의된 것들이 아닙니다.
"컴포넌트 외부에서 정의한 변수나 함수"의 경우 추가할 필요는 없습니다. 이들 또한 컴포넌트 내부에서 선언/정의된 것들이 아닙니다.
useEffect
훅을 사용할 때 주의해야 할 점으로 "무한 루프"를 조심해야 합니다. useEffect
훅의 인수로 전달한 콜백 함수(effectFn)은 특정 조건에 따라 컴포넌트가 리렌더링된 이후에 호출됩니다.
무한 루프가 발생할 수 있는 상황 두 가지가 있습니다.
두 번째 인수로 dependencies 배열을 전달하지 않고, effectFn 내부에서 객체 타입의 상태를 변경하는 상태 변경 함수를 호출하는 경우
두 번째 인수로 전달한 dependencies 배열의 요소에 객체 타입의 값을 요소로 갖고 있고, effectFn 내부에서 객체 타입의 상태 변경 함수를 호출하는 경우
즉, effectFn 내부에서는 "객체 타입의 상태값을 변경하는 상태 변경 함수를 호출"하며, "dependencies 배열의 요소로 객체 타입의 값"을 갖고 있거나, "dependencies 배열을 전달하지 않는 경우" 무한루프가 발생합니다.
첫 번째 상황의 경우 dependencies 배열을 전달하지 않고, effectFn만을 전달하면 매 리렌더링 이후에 effectFn이 호출됩니다.
이때 effectFn 내부에서 객체 타입의 상태값을 변경하는 상태 변경 함수를 호출하는 경우 effectFn의 호출과 컴포넌트 리렌더링이 계속해서 반복되어 실행됩니다.
이는 "객체 타입의 값은 실행될 때마다 새롭게 생성"되기 때문에 기존 상태와 단순 비교시 언제나 다른값으로 해석되는 자바스크립트의 특징때문에 발생됩니다.
컴포넌트가 첫 렌더링된 이후 effectFn 콜백 함수가 호출됩니다. 이는 모든 EffectFn 콜백 함수에 해당합니다.
effectFn 콜백 함수 내부에서 객체 타입의 상태값을 변경하는 상태 변경 함수를 호출한다면 컴포넌트가 다시 재평가되고 리렌더링됩니다.
리렌더링된 이후 dependencies 배열이 없기 때문에 다시 effectFn 콜백 함수가 호출되고 내부에서는 다시 상태 변경 함수를 호출하여 다시 컴포넌트를 재평가하고 리렌더링하는 과정을 거칩니다. 이러한 과정이 계속해서 반복됩니다.
import { useState, useEffect } from 'react';
const MyComponent = () => {
// 빈 배열을 상태로 추가
const [array, setArray] = useState([]);
// dependencies 배열을 전달하지 않는 useEffect
useEffect(() => {
// 상태 변경 함수 호출
setArray([]);
,,,
});
,,,
};
export default MyComponent;
위 MyComponent가 매번 재평가되어 리렌더링된 이후에 useEffect의 인수로 전달한 콜백 함수가 호출됩니다. 내부에서는 setArray에 인수로 빈 배열을 전달하면서 호출합니다.
중요한 것은 기존에 갖고 있던 상태가 빈 배열이라 하더라도 기존 상태인 빈 배열의 메모리 주소와 인수로 전달한 빈 배열의 메모리 주소가 다르기 때문에 리액트는 서로 다른 값으로 인식하여 컴포넌트를 다시 재평가되어 리렌더링합니다. 리렌더링된 이후 dependencies 배열이 없기 때문에 다시 effectFn이 호출되는 무한 루프에 빠지게 됩니다.
import { useState, useEffect } from 'react';
const MyComponent = () => {
const [array, setArray] = useState([]);
const myFunc = () => {
,,,
};
useEffect(() => {
setArray([]); // -> 상태 변경 함수 호출
myFunc();
,,,
}, [myFunc]); // -> 함수도 객체이며 참조형 타입이다
,,,
};
export default MyComponent;
이번에는 MyComponent 내부에서 정의한 myFunc 함수는 effectFn 함수 내부에서 사용하고 있기 때문에 dependencies 배열에 myFunc를 추가해주었습니다.
MyComponent가 첫 렌더링이 된 이후에 effectFn 함수가 호출됩니다.
effectFn 내부에서는 setArray 상태 변경 함수가 호출되어 상태가 변경되고 MyComponent가 다시 재평가됩니다.
중요한 것은 컴포넌트가 재평가될 때 myFunc 함수도 재평가되어 함수 객체를 다시 생성하게 됩니다. 즉, myFunc의 값이 달라지게 된 것입니다.
이전 배열의 요소값과 재평가된 배열의 요소값이 서로 다르기 때문에 MyComponent가 리렌더링된 이후에 다시 effectFn이 호출되고 상태 변경 함수로 인해서 컴포넌트가 재평가되는 무한 루프에 빠지게 됩니다.
useEffect
훅에 전달하는 콜백 함수(effectFn)의 반환값은 함수를 작성해야 합니다. 반환값으로 작성된 함수를 clean-up 함수라고 합니다. 반환값으로 작성한 clean up 함수는 "다음 effectFn이 호출되기 이전에 호출"됩니다.
그리고 clean-up 함수가 effectFn에 선언된 식별자를 참조하는 경우에는 effectFn의 렉시컬 환경(상위 스코프)을 기억하는 "클로저"로 동작하게 됩니다.
즉, clean-up 함수가 실행될 때는 이전 effectFn의 렉시컬 환경을 상위 스코프로 기억하는 클로저로 동작합니다.
clean-up 함수의 동작은 아래 세 가지 경우가 존재합니다.
컴포넌트가 리렌더링된 이후 effectFn이 호출되기 이전에 매번 호출됩니다. 그리고 컴포넌트가 돔에서 언마운트되기 직전에 호출됩니다.
컴포넌트가 돔에서 언마운트되기 직전에 clean-up 함수가 호출됩니다.
컴포넌트가 리렌더링된 이후 다음 effectFn이 호출되기 전에 이전 effectFn의 반환값인 clean-up 함수가 호출됩니다. 그리고 컴포넌트가 돔에서 언마운트되기 직전에 호출됩니다.
주의할 점으로 컴포넌트가 리렌더링되기 전에 clean-up 함수가 호출될 것이라고 예상했지만, 실제로는 컴포넌트가 리렌더링이 된 이후에 이전 useEffect 훅의 clean-up 함수가 호출되고 다음 useEffect의 effectFn이 호출됩니다.
즉, clean-up이 호출된 다음에 리렌더링이 되지 않고 "리렌더링이 된 이후"에 이전 clean-up함수가 호출되고, 다음 effectFn이 호출된다는 점을 주의해야 합니다.