프로그래밍에서 Side Effect는 '부작용'이 아닌 '부수효과'의 의미로 정의하는게 적합함.
Side Effect는 코드가 의도한 주된 효과 외에 추가적으로 발생하는 효과이다. 함수가 하고자 하는 본질적 역할은 Input을 받아서 Output을 산출하는 것이다. 따라서 함수의 side Effect란 Input을 받아 Output을 산출하는 것 이외의 모든 행위를 의미한다.
const sum = (x) => {
return x + 1;
}
위의 sum 함수는 x(input)을 받아서 X+1(output)을 반환만을 하고 있으므로 side effect가 없다. 이를 순수함수라고 부른다.
const num = 1;
const sum = (x) => {
return x + num;
}
위 함수는 input 외에 외부의 값 num을 읽어오고 있어서 side effect가 존재한다.
let num;
const sum = (x) => {
num = x + 1;
}
위 함수는 외부의 num 변수의 값을 변경하고 있다. 이것도 side effect임.
const printNum = (x) => {
console.log(x);
};
const changeTitle = (newTitle) => {
const title = document.getElementById('title');
title.innerText = newTitle;
};
위 두 함수 또한 side effect를 발생하고 있다.
DOM을 조작하고 console에 특정 문자를 출력하는 행위 또한 함수 외부에 존재하는 DOM과 console의 상태를 변경하는 것이기 때문이다.
프로그래밍에서 side effect는 함수가 input을 받아 output을 산출하는 과정에서
1. 외부의 값을 읽어오는 행위
2. 외부의 값을 변경하는 행위
를 의미한다.
프로그래밍에서 side effect는 기피해야 한다. side effect가 있는 함수는 동작 결과를 예측하기 쉽지 않기 때문이다. const sum = (x) => x + 1 이란 함수는 항상 우리가 1을 인자로 넣으면 2가 return 될 것이라 예측할 수 있지만, const sum = (x) => x + num 이란 함수는 num이란 값이 어떻게 변할지 모르기 때문에 함수의 결과를 예측하기 어려워지기 때문이다.
따라서 개발자들은 side effect를 최소화하면서 프로그램을 설계하고 side effect가 필요하면 반드시 통제 가능하게 하여 프로그램 유지보수에 악영향을 최소화 해야한다.
리액트에서 rendering이란 state, props를 기반으로 UI요소를 그려내는 행위이다. 리액트에서 화면을 컴포넌트 단위로 구성하고 그 컴포넌트들은 리액트의 함수 컴포를 이용해서 만들어낸다.
함수 컴포넌트에서 input은 state, props / output은 JSX이다.
따라서 위처럼 표현할 수 있다.
(state는 실제로 인자가 아닌 내부에서 useState hook을 통해 가져오는 것이고 개념상 외부에서 가져오는 값이라 위 도식으로 표현)
함수 컴포에서 Side Effect 사례
Data Fetching
프론트엔드가 백엔드 API를 통해 기존에 저장된 데이터를 가져오는 행위
DOM 접근 및 조작
리액트는 개발자가 직접 DOM에 접근하는 것을 대신해주기 때문에 DOM에 접근할 일이 드물지만 documnet 객체에 scroll eventListener를 등록하는 등의 상황에서는 DOM에 접근, 직접 조작이 필요함.
구독
구독이란 어떤 것의 변화를 계속해서 지켜보고 변화가 발생하면 특정한 액션을 취하는 것.
웹개발에서 흔히 구독하는 것은 '시간'
setTimeout, setInterval 메서드.
Data Fetching, 구독 등의 side effect는 신중히 다뤄야 함. 리액트에서 side effect는 언제 어떻게 발생시켜야 하나?
const App = () => {
return <h1>Hello World</h1>;
};
위 코드에서 side effect를 발생시키고 싶다면?
const App = () => {
const doSideEffect = () => {
// do some side effect
};
doSideEffect();
return <h1>Hello World</h1>;
};
위와 같이 렌더링 단계에서 side effect를 발생시키면 두 가지 문제 발생
1. side effect가 렌더링을 block
2. 매 렌더링마다 side effect가 수행됨
const App = () => {
const doSideEffect = () => {
// do some side effect
};
doSideEffect();
return <h1>Hello World</h1>;
};
기본적으로 코드는 위에서 아래 방향으로 순차적 실행됨. App 컴포는 doSideEffect()가 완료될 때 까지 JSX를 리턴하는 코드로 넘어가지 않는다. 즉 사이드이펙트가 끝나기 전까지 렌더링을 하지 못하고 멈춰있게 되는 것이고 UI 업데이트에 오랜 시간이 소요되어 느린 사용자 경험을 제공하게 됨.
특정한 side effect들은 매번 실행될 필요가 없을 수 있음.
예를 들면 인스타에서 피드 데이터를 받아 피드 리스트를 보여주는 화면이라면 최초에는 피드 데이터를 가져오는(Data Fetching) side effect가 필요하다. 이 때 매번 Fetching은 비효율적이다.
const App = () => {
// 코드 생략
// data fetching side effect
getFeeds();
return 피드리스트;
};
만약 피드에 좋아요를 눌러 하트 색깔을 변경시키면 컴포넌트를 다시 호출하여 리렌더링을 수행하게 된다.
이 때 사이드 이펙트를 다시 한번 수행하게 되고 이는 app을 비효율적으로 만든다.
이를 만족시켜주기 위해서 리액트에는 useEffect라는 hook이 있다.
useEffect에서 React는 side effect를 편리하고 안전하게 발생시킬 수 있게 돕는 hook이다.
useEffect(콜백 함수)
콜백함수에서 특정한 side effect를 수행시킬 수 있음.
const App = () => {
// 코드 생략
doSideEffect();
return <h1>Hello, Wecoder</h1>;
};
위 코드는 side effect를 렌더링 전에 실행 blocking을 발생시킴.
import { useEffect } from 'react';
const App = () => {
// 코드 생략
useEffect(doSideEffect);
return <h1>Hello, Wecoder</h1>;
};
useEffect내의 콜백함수는 랜더링이 모두 완료된 후 실행 됨.
실제 동작 예시
재렌더링마다 side effect는 무조건 발생하고 있다.
여기에 조건을 줘서 조건 충족시 실행하게 할 수 있을까?
useEffect(콜백함수, 의존성 배열)
useEffect는 콜백함수 외에 의존성 배열(dependencyArray)라는 두번째 매개변수를 가진다.
의존성 배열의 타입은 배열이고 side effect발생 여부르 결정짓는 조건이다.
useEffect의 동작 방식
1. 의존성배열이 전달되지 않았다면 매 렌더링마다 콜백 함수 호출
2. 의존성 배열이 전달되었다면 의존성 배열의 값을 검사.
즉 의존성 배열은 실행시킬 타이밍을 결정짓는다.
useEffect(() => {
// data fetching side effect
}, []);
의존성 배열에 아무 요소도 없기 때문에 데이터 패칭은 최초 렌더링 때 1회 실행된다.
Rendering & Effect Cycle 다이어그램 출처: https://dmitripavlutin.com/react-useeffect-explanation/
side effect에는 여러 종류가 있고 반드시 clean up이 필요한 이펙트가 있을 수도 있다.
그 기준은 해당 side effect가 지속적으로 남아있는가?를 생각하면 된다.
아래 코드는 클린업 필요 X
useEffect(() => {
console.log('Hello, Wecoder');
}, []);
useEffect(() => {
const countTime = () => {
console.log('100ms가 지났습니다.');
};
setInterval(countTime, 100);
}, []);
위 사이드이펙트는 클린업이 필요함.
100ms마다 countTime함수를 실행하는데 의존성 배열이 비어있으므로 최초 렌더링 부터 side effect가 실행됨.
하지만 이 side effect를 cleanup하지 않으면 컴포넌트가 unmount되어 setInterval을 통한 구독이 필요 없어진 상황에서도 계속해서 콘솔이 출력됨.
또 다른 상황
useEffect(() => {
const button = document.getElementById('consoleButton'); // 1
const printConsole = () => {
console.log('button clicked');
}; // 2
button.addEventListener('click', printConsole); // 3
});
이 useEffect는 의존성 배열이 없기 때문에 매 렌더링마다 실행된다. button에 이벤트리스너가 렌더링마다 추가되기 때문에 첫 렌더링에는 한번만 출력되던 콘솔로그가 렌더링이 증가할수록 비례하여 출력횟수가 늘어나는 현상이 발생한다.
(실제로 위와 같은 직접 DOM에 접근, 이벤트 리스너 부착은 지양, 예시를 위한 코드일 뿐이다.)
useEffect에서 전달한 콜백 함수에서 clean up 하는 함수를 리턴하면 됨.
문제가 생기는 예시)
useEffect(() => {
const button = document.getElementById('consoleButton');
const printConsole = () => {
console.log('button clicked');
};
button.addEventListener('click', printConsole);
});
클린업 예시)
useEffect(() => {
const button = document.getElementById('consoleButton');
const printConsole = () => {
console.log('button clicked');
};
button.addEventListener('click', printConsole);
// side effect를 clean up 하기 위한 함수를 선언한다.
const removeEventListener = () => {
button.removeEventListener('click', printConsole);
};
// clean up 함수를 return 한다.
return removeEventListener;
});
발생시킨 side effect를 상쇄하기 위한 함수를 만들고 return 해주기.
clean up 함수 호출 경우
1. 다음 side effect 발생시키키 전
2. 컴포넌트가 unmount될 때
위 코드 동작 원리
1. useEffect에 의존성 배열X -> 매렌더링마다 side effect 실행
2. 리렌더링이 발생해서 useEffect가 재호출되는 상황 발생
매 렌더링마다 실행
useEffect(()=>{
//실행코드
})
최초 렌더링 때 1회 실행 후 state 변경 때만 실행
useEffect(()=>{
//실행코드
},[의존성배열(state)])
최초 렌더링때 1회 실시
useEffect(()=>{
//실행코드
},[])
클린업(unmount or side effect 실행 전에 실시)
useEffect(()=>{
//구독(타이머, 이벤트리스너)
return () => {clean up}
// removeEvent... / clearInterval(콜백함수)
// 클린업 시기: unmount, 사이드 이펙트 실행 전
},[])