이전에 useEffect 훅을 사용할 때 발생했던 에러 때문에 동작 원리가 궁금해 공부를 했었는데, 오늘은 그 내용을 조금 더 깊게 정리하고 기록으로 남겨 공유하고자 한다. (나를 꽤 괴롭게 했던... 카운터 컴포넌트와 그 에러에 대한 기록)
대부분의 내용은 리액트의 공식 문서 (구버전 및 신버전)을 참고하여 작성되었다.
useEffect 는 무엇이고, 어떤 상황에서 사용할 수 있는가?
공식문서에 따르면, Effect 훅을 사용하면 함수형 컴포넌트에서 Side Effect를 발생시킬 수 있다고 한다.
일반적인 의미에서 Side Effect는 부수 효과, 즉 원래 목적과 다르게 외부 상태를 변경하는 등, 변화를 발생시키는 효과 (혹은 부작용) 을 의미한다. 함수형 프로그래밍의 기본 개념인 '순수 함수' 란 이러한 부수 효과가 존재하지 않는, 즉 외부 상태를 변경시키지 않고 동일한 인자 값에 대해 늘 동일한 결과를 수행하는 함수를 의미하기도 한다.
그렇다면 리액트의 함수형 컴포넌트에서 발생하는 Side Effect란 무엇이고, 왜 꼭 useEffect를 사용해 발생시켜야 하는지 궁금해진다.
일단 리액트에서의 Side Effect 는 컴포넌트가 렌더링 되는 동안에 발생하는 작업을 의미한다. 데이터 가져오기, 구독 설정하기, 수동으로 React 컴포넌트의 DOM 을 수정하는 것까지 이러한 모든 것을 Side Effect 라고 볼 수 있다. 이러한 행위들이 부수 효과가 되는 이유는, 기본적으로 리액트 컴포넌트는 순수 함수로 작성되어야 하기 때문이다.
리액트의 컴포넌트는 JSX 를 반환하도록 구성된다. 따라서 리액트의 컴포넌트가 순수 함수여야 한다는 의미는, 리액트에서 일어나는 렌더링 프로세스가 순수하게 동작해야 한다는 의미이다. JSX 를 반환하는 것 이외에 기존에 존재하던 값을 변경시키는 것은 의도치 않은 결과를 만들 수 있다. 공식문서에 소개된 아래 예제를 살펴보자.
let guest = 0;
function Cup() {
// Bad: changing a preexisting variable!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup /> //2
<Cup /> //4
<Cup /> //6
</>
);
}
Cup 이라는 함수형 컴포넌트를 렌더링하기 위해 TeaSet 컴포넌트에서 이를 3번 호출하고 있다. Cup 컴포넌트 내부에서는 함수 외부에 존재하는 값을 함수가 호출될 때마다 변경하도록 작성되어 있고, 이 값을 참조한 JSX 를 리턴하고 있다. 이 함수를 여러번 호출할 때마다 계속해서 다른 JSX를 생성하게 되고, 이는 동일한 인자에 대해 동일한 결과를 반환한다는 순수함수의 규칙에 위배된다.
순수 함수의 규칙을 지키기 위해서는 Cup 컴포넌트를 호출할 때 렌더링할 값을 Props로 전달하는 방법이 있다. 이러한 방법을 통해 동일한 input에 언제나 동일한 output 을 출력한다는 일관성을 지킬 수 있고, 외부의 특정한 값에 의존하지 않고 독립적이기 때문에 컴포넌트의 재사용성도 높일 수 있다.
그러나, Side Effect 가 매번 나쁜 것만은 아니며 어느 시점엔 Side Effect 가 반드시 필요한 순간이 온다. 함수의 외부 상태를 바꾸어 프로그램이 동작하도록 해야할 필요가 있기 때문이다. 어떤 이벤트가 발생했을 때 특정 데이터를 업데이트하여 화면을 변경한다던지, 서버와 통신을 하여 서버 상태를 변경한다던지 프로그램에 필요한 모든 행위가 Side Effect 에 속한다. 리액트에서 Side Effect 를 발생시키는 방법으로는 Event 그리고 useEffect가 있다.
공식 문서에 Effects 와 Events 를 비교하여 설명한 내용이 있으니 잠시 짚고 넘어가보자.
결론부터 말하면, 렌더링 자체에 의해서 일어나는 부수 효과는 Effect 에 속하고, 유저 상호작용 - 즉 이벤트 핸들러에 의해 발생하면 Events 로 분류되는 것이다(Events 는 Side Effect 를 포함하는 개념이 된다). 채팅방을 예시로 들고 있는데, 채팅방 컴포넌트가 화면에 렌더링 될 때마다 채팅 서버에 연결을 해야한다면, 서버와의 연결은 컴포넌트 함수 외부 즉, 부수 효과에 속한다. 렌더링이 되는 것 자체는 click 이나 input 과 같이 유저와의 상호작용과 연관이 없으므로, 이 때는 useEffect 훅을 이용해 부수 효과를 일으켜야 한다.
이쯤에서 정리를 해보자.
1. 리액트의 컴포넌트는 순수 함수로, 외부 환경에 영향을 받지 않고 독립적으로 존재하여 예측 가능성 그리고 재사용성을 유지해야한다.
2. 하지만 프로그램의 필수 기능이 구현되려면 외부 api 와 통신을 하는 등 부수 효과-Side Effect 가 반드시 필요하다.
3. 유저 상호작용 기반이 아닌, 렌더링과 연관지어 발생되는 부수 효과의 경우 useEffect 훅을 사용해 발생시킬 수 있다.
결론적으로 순수 함수 원칙을 위배하지 않으면서, Side Effect 를 발생시키고 처리하기 위해 사용하는 훅이라고 할 수 있다. 그렇다면, 어떻게 동작하고 실행되길래 이러한 것이 가능한 걸까?
궁금하니 다음 주제로 넘어가 더 알아보자.
useEffect 훅은 어떻게 실행되는가?
일단 useEffect 훅을 사용해 Side Effect를 일으키는 방법에 대해 코드 예시와 함께 알아보자. 공식문서의 useEffect 레퍼런스 항목을 보면 다음과 같이 나와있다. setup 과 dependencies 를 옵셔널 인자로 받을 수 있다.
각 인자에 대해 조금 더 자세히 살펴보자. (공식문서에도 친절하게 소개되어 있다.)
- setup
- Effect 로직이 들어가는 함수로, cleanup 함수를 선택적으로 반환할 수 도 있다.
- 컴포넌트가 DOM 에 처음 추가될 때 - 즉 mount 되는 시점에 React 는 이 setup 함수를 실행시킨다.
- 그리고 dependencies 가 변경이 될 때마다 setup 함수를 실행하는데, 만약 이 때 cleanup 함수가 제공되었다면 cleanup 함수를 수행한 뒤에 setup 함수를 실행한다.
- 그리고 나서 컴포넌트가 unmount 될 때 cleanup 함수를 한 번 더 실행한다.
- dependendcies (옵셔널)
- 의존성 배열이라고도 하며, 말 그대로 의존성 목록을 전달하는 역할을 한다.
- 의존성 배열에 전달되는 항목은 props 나 state 혹은 컴포넌트 바디 안에서 선언된 변수나 함수등의 값이 될 수 있다.
- 이 의존성 배열에 전달된 값에 변동이 생기는 경우, setup 함수가 실행되는데, 이 변동 여부를 파악하기 위해 React 는 Object.is 메서드를 사용해 이전 값과 현재 값을 비교하는 연산을 수행한다.
- 이 의존성 배열을 전달하지 않는 것과, 빈 배열을 전달하는 것 그리고 앞서 설명한 값을 넣어 전달하는 것 모두 다른 방식으로 setup 함수를 실행시키므로 주의가 필요하다. 이에 대한 예시는 공식문서로 대체하겠음!
위 레퍼런스를 통해 알 수 있는 것은, useEffect 를 사용하면 컴포넌트가 마운트 될 때, 업데이트 될 때, 그리고 언마운트 되기 직전에 특정한 동작을 수행하도록 만들 수 있다는 것이다. 그래서 이를 활용해 아래와 같은 코드를 작성할 수 있다.
import { useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
위 코드는 채팅방 입장시 서버 연결 및 종료를 담당하는 로직이다. ChatRoom 컴포넌트가 마운트 될 때 setup 함수를 실행해 커넥션을 생성하고, 컴포넌트가 언마운트 되거나, url 이나 roomId 가 어떤 원인에 의해 변경될 때마다 연결을 끊고 새롭게 생성하는 방식으로 동작하고 있다.
눈여겨 볼 점은, setup 함수와 cleanup 함수가 대칭을 이루고 있다는 점이다. React Strict 모드에서는(개발 모드에서) 올바르게 로직이 구현되었는지 확인하기 위해 setup + cleanup 로직을 실제 setup 로직을 수행하기 이전에 한 번 더 실행한다. 만약 이 때 뭔가 제대로 동작하지 않고 문제가 발생한다면, cleanup 함수가 setup 함수를 멈추거나 되돌리는 로직이 아닌 경우일 수 있으니 확인이 필요하다.
React Hooks 는 어떻게 동작하는가?
그렇다면 useEffect 내부는 어떤 방식으로 구현이 되어있을까? 사실 이 부분이 궁금해 글을 작성하게 되었다.
서두에 언급했지만, 이전에 useEffect 를 사용해 interval 함수를 만든 적이 있는데, 이 때 cleanup 을 setup 과 대칭되도록 구현하지 않아서인지 자꾸 이전 렌더링에서의 값을 기억하는 문제가 발생한 경험이 있다. (링크) 이 때 리액트의 훅이 closure 의 원리를 기반으로 구현되어 있다는 것을 알게 되었는데, 오늘은 useEffect 훅을 간단하게 직접 구현하면서 그 동작 원리를 파악해보고자 한다.
useEffect 는 React hooks 의 한 종류이기 때문에 먼저 React 에서 Hook 이 어떻게 구현되어 있는지 알아보자.
(아래 내용과 예제 코드는 이 글을 참고하여 작성되었다.)
아래는 클로저를 기반으로 MyReact 라는 모듈을 만들어 useState 와 비슷하게 동작하도록 만든 코드이다.
const MyReact = (function () {
// 클로저를 통해 접근할 수 있는 상태값
let _state;
// useState 는 initVal 을 받아 상태값을 반환하고 업데이트 할 수 있다.
function useState(initVal) {
const state = _state || initVal;
const setState = (newVal) => {
_state = newVal;
};
return [state, setState];
}
// render 메서드에서는 전달받은 컴포넌트의 render 메서드를 실행하고, 컴포넌트를 리턴한다.
function render(Component) {
const C = Component();
C.render();
return C;
}
return { useState, render };
})();
// 구현된 useState 를 사용하는 카운터 컴포넌트
function Counter() {
const [count, setCount] = MyReact.useState(1);
return {
render: () => console.log("현재 카운트:", count),
addCount: () => setCount(count + 1)
};
}
// 카운터 컴포넌트에서 count 상태값을 통해 MyReact 내부의 지역변수인 _state 값에 접근하고 업데이트한다.
// 변수는 즉각적인 업데이트가 되지 않기 때문에 여러번 render 를 실행하여 확인하고 있다.
var CounterComp = MyReact.render(Counter);
CounterComp.addCount(); // 1
var CounterComp = MyReact.render(Counter);
CounterComp.addCount(); // 2
위 코드에서는 _state 하나의 변수로 상태값을 관리하기 때문에, 하나의 컴포넌트에서 여러개의 상태 값을 동시에 사용하하는 경우 덮어 씌워지는 문제가 발생할 수 있다. 그렇기 때문에 React 내에서는 각 값을 배열로 담아서 관리하게 된다. 이 역시 클로저의 원리를 이용해 배열의 인덱스 값을 기억하는 방식으로 구현될 수 있다.
const MyReact = (function () {
// 상태 값이 담길 배열과 idx 값을 MyReact 의 지역변수로 선언한다.
let hooks = [];
let idx = 0;
// 위 코드와 비슷한데, 상태값을 배열에 저장해두고 idx 를 통해 접근한다.
function useState(initVal) {
const state = hooks[idx] || initVal;
// useState 내에서 현재 idx 를 curIdx 로 기억한다.
// 이 역시 클로저이기 때문에, setState 가 호출될 때마다 curIdx 를 통해 접근하여 새 값으로 갱신된다.
const curIdx = idx;
const setState = (newVal) => {
hooks[curIdx] = newVal;
};
// 한 번 useState가 호출되면, 그 다음 위치에 새 상태값이 추가될 수 있도록 idx 를 증가시킨다.
idx++;
return [state, setState];
}
function render(Component) {
idx = 0; // 렌더링할 때 훅 인덱스 초기화
const C = Component();
C.render();
return C;
}
return { useState, render };
})();
// 한 컴포넌트에서 두 개의 state를 사용해도, 서로 다른 값을 기억할 수 있다.
function Counter() {
const [count, setCount] = MyReact.useState(1);
const [text, setText] = MyReact.useState("a");
return {
render: () => {
console.log("현재 카운트:", count, " 현재 텍스트:", text);
},
updateState: () => {
setCount(count + 1);
setText(text + "a");
}
};
}
var CounterComp = MyReact.render(Counter);
CounterComp.updateState(); // 현재 카운트: 1 현재 텍스트: a
var CounterComp = MyReact.render(Counter);
CounterComp.updateState(); // 현재 카운트: 2 현재 텍스트: aa
var CounterComp = MyReact.render(Counter);
CounterComp.updateState(); // 현재 카운트: 3 현재 텍스트: aaa
그럼 다음으로는 useEffect 훅을 구현해보자. 위에서 만든 MyReact 모듈안에 useEffect 함수를 정의해 useState 와 비슷하게 사용할 수 있다. useEffect 는 위에서 설명한 바와 같이 컴포넌트 마운트, 언마운트 그리고 의존성 업데이트 여부에 따라 Effect 를 실행하는 훅이다. 따라서 인자로 실행할 effect 함수와 의존성 배열을 받아 비교하는 작업을 거친다.
// MyReact 모듈 안에 선언하여 사용한다.
// 콜백으로 실행할 effect 함수와, 의존성 배열을 인자로 받는다.
function useEffect(effect, deps) {
// 먼저 이전에 useEffect가 호출되어 의존성 배열이 hooks 배열에 존재하는지 확인한다.
const prevDeps = hooks[idx];
// 만약 prevDeps 가 존재하지 않거나, 변화가 생겼다면 effect 를 실행한다.
if (isFirstCall(prevDeps) || isDepsChanged(prevDeps, deps)) {
effect();
}
// 마찬가지로 hooks 에 현재 의존성 배열을 저장하고, idx 값을 증가한다.
hooks[idx] = deps;
idx++;
}
// 위에서 사용된 util 함수
function isDepsChanged(prevDeps, deps) {
// Object.is 라는 엄격한 비교를 통해 현재 의존성 배열과 이전 의존성 배열 요소의 변화를 감지한다.
return deps.some((dep, idx) => !Object.is(dep, prevDeps[idx]));
}
function isFirstCall(prevDeps) {
return prevDeps === undefined;
}
앞서 구현한 카운터 컴포넌트에서 아래와 같이 사용할 수 있다.
function Counter() {
const [count1, setCount1] = MyReact.useState(0);
const [count2, setCount2] = MyReact.useState(0);
MyReact.useEffect(() => {
console.log("effect 발생");
}, [count1, count2]);
return {
render: () => {
console.log("카운트 1:", count1, " 카운트 2:", count2);
},
updateState: () => {
setCount1(count1 + 1);
setCount2(count2 - 1);
}
};
}
결론
useEffect 를 비롯한 React 의 Hooks 는 간단한 예제 코드로 살펴본 바와 같이 클로저의 원리에 기반하여 동작한다. 이로 인해 예상치 못하게 발생하는 문제를 지칭하는 용어로 클로저 트랩 이라는 개념이 있다고 한다. 간단한 코드 예시에서는 미처 다루지 못했지만, useEffect 의 cleanup 함수를 사용해 컴포넌트 언마운트 직전에 발생할 작업을 설정할 수 있고, 이 cleanup 함수는 이름처럼 정리를 하는 함수이기 때문에 setup 함수와 대칭되도록 만들어야 한다.
위에서 살펴본 코드는 동작을 쉽게 이해할 수 있는 개념적인 모델일 뿐, 실제 리액트는 훨씬 더 복잡한 코드로 구현되어 있을 것이다. 다만 이렇게 리액트의 기본 컨셉과 추상화되어 자주 사용되는 기능에 대해서 대략적인 동작 원리를 파악함으로써 예상치 못한 에러 발생을 줄이고 조금 더 안정적인 코드를 만들 수 있으리라 생각된다.
레퍼런스
https://www.rinae.dev/posts/getting-closure-on-react-hooks-summary
https://ko.reactjs.org/docs/hooks-reference.html#useeffect
https://react.dev/reference/react/useEffect
'Stacks > React.js' 카테고리의 다른 글
React Hook Form (0) | 2022.09.27 |
---|---|
[React] UseEffect로 fetch API 요청하기 (0) | 2022.08.08 |
[React] 리액트 Props 와 State 를 알아보자! (0) | 2022.08.02 |
[React] SPA 는 무엇인가? / React Router 활용하기 (0) | 2022.08.01 |
[React] 리액트는 무엇이고, 왜 리액트를 사용해야 하는가? (0) | 2022.07.20 |