리액트 훅
함수 컴포넌트와 훅의 등장

1. 머릿말
이 글은 함수 컴포넌트와 훅을 다룹니다.
페이스북은 심각해지는 사용자 경험과 개발자 경험을 해소하느라 리액트를 만들었지만 그 뒤에도 문제가 생겼습니다. 바로 클래스 컴포넌트입니다.
클래스 컴포넌트를 만들려면 많은 보일러 플레이트 코드를 써야 합니다. 신규, 중견 개발자들이 클래스 컴포넌트는 복잡하고 짜증난다는 말을 많이 해요.
...
클래스 컴포넌트는 사람들한테만 어려운 게 아니라 기계들도 어려워해요. 컴파일도 길어지고 복잡해지거든요.
...
(클래스 컴포넌트의) 문제는 상태나 생명 주기 코드를 추가할 때 쓸만한 쉽고 가벼운 원리가 없었어요.
- 2018 리액트 컨퍼런스 -
함수 컴포넌트는 초보 개발자들을 위한『옵션』이 아니라 개선 목적으로 나왔습니다.
2. useState
“useState는 클로저를 활용하고 setState는 비동기 실행이다.”
클래스 → 함수형으로 바뀐 게 얼마나 다행인지 잠시 보겠습니다.
class Counter extends Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<>
<span>{this.state.count}</span>
<button onClick={this.handleClick}>Click me</button>
</>
);
}
}
class Counter extends Component {
state = { count: 0 };
handleClick = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<>
<span>{this.state.count}</span>
<button onClick={this.handleClick}>Click me</button>
</>
);
}
}
const Counter = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
}
return (
<>
<span>{count}</span>
<button onClick={handleClick}>Click me</button>
</>
);
}
2-1. 상태로 업데이트.
클래스형의 render, 함수형의 return은 컴포넌트를 렌더링합니다.
가상 DOM은 무슨 일이 발생하면 상태 변경을 기준으로 전후 객체를 얕게 비교하고 업데이트를 결정하는데, 그 때문에 상태 값이 객체라면 주의가 필요합니다.
const [obj, setObj] = useState({ name: "React", like: 1299 });
const handleClick = () => {
obj.name = "hahaha";
setObj(obj);
console.log(obj);
};
return (
<>
<button onClick={handleClick}>{obj.name}</button>
</>
);
객체는 가변성이어서 값만 바꾸면 참조값이 유지된다.
→ 가상 DOM은 상태에 변화가 없다고 생각하고 무시한다.
→ 이럴 땐 새 객체를 만들어서 할당한다.
const [obj, setObj] = useState({ name: "React", like: 1299 });
const handleClick = () => {
const name = "hahaha";
const newObj = { ...obj, name };
setObj(newObj);
console.log(obj);
};
return (
<>
<button onClick={handleClick}>{obj.name}</button>
</>
);
3. useEffect
“비동기라서 많이 쓰는 이펙트 훅.”
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() {
document.title = `You clicked ${this.state.count} times`;
}
useEffect(() => {
document.title = `You clicked ${count} times`;
});
3-1. 의존성 배열
useEffect(() => {
document.title = `You clicked ${count} times`;
},[]);
useEffect(() => {
document.title = `You clicked ${count} times`;
},[count]);
3-2. 이벤트 등록 및 회수
useEffect(() => {
function 마우스감지(e) {..}
window.addEventListener("scroll", 마우스감지);
return function cleanup() {
window.removeEventListener("scroll", 마우스감지);
};
}, []);
useEffect(() => {
function 마우스감지(e) {..}
window.addEventListener("scroll", 마우스감지);
return () => {
window.removeEventListener("scroll", 마우스감지);
};
}, []);
3-3. 실행 타이밍
“useEffect는 layout, paint가 끝나고 지연된 이벤트들을 처리할 때 실행합니다.”
useEffect는 대체로 아래 타이밍에 실행합니다.
- 첫 렌더링(마운트) 이후
- 리렌더링 이후
- 컴포넌트 제거 후
컴포넌트는 마운트 직후 paint도 순식간에 해서 화면에 나옵니다. 그 후로 useEffect가 비동기 실행됩니다.
useEffect와 useLayoutEffect는 비슷하지만 미묘한 차이가 있는데 아래에서 자세히 쓰겠습니다.
4. useLayoutEffect
“모든 DOM 변화가 끝나면 동기적으로 실행합니다. DOM에서 레이아웃을 읽어내 리렌더링하고 싶을 때만 쓰세요. componentDidMount(컴포넌트가 DOM 트리에 삽입된 직후), componentDidUpdate(브라우저가 repaint하기 전)과 동일한 시점에 발생합니다.”
“useLayoutEffect는 정말 필요할 때 쓰세요.”
- React -
“SSR, SSG에선 useLayoutEffect 사용에 주의하세요.”
- Next.js -

useEffect와 useLayoutEffect는 거의 비슷하면서 다릅니다.
window.addEventListener("DOMContentLoaded", () => {
console.log("DOM 트리 생성");
console.log(window.document.documentElement);
});
window.addEventListener("load", () => {
console.log("자원 로드");
console.log(window.document.documentElement);
});
root.render(<App />);
function App() {
const [effect, setEffect] = useState("App");
const [layout, setLayout] = useState("App layout");
console.log(`App 읽는 중...\n effect: ${effect},\n layout: ${layout}`);
useLayoutEffect(() => {
setLayout("App layout effect");
console.log(
`App useLayoutEffect 실행 \n effect: ${effect},\n layout: ${layout}`
);
}, []);
useEffect(() => {
setEffect("App side effect");
console.log(`App useEffect 실행 \n effect: ${effect},\n layout: ${layout}`);
}, []);
return (
<>
<Child />
{effect}
</>
);
}
const Child = () => {
const [effect, setEffect] = useState("Child");
const [layout, setLayout] = useState("Child layout");
console.log(`Child 읽는 중... \n effect: ${effect},\n layout: ${layout}`);
useLayoutEffect(() => {
setLayout("Child layout effect");
console.log(
`Child useLayoutEffect 실행 \n effect: ${effect},\n layout: ${layout}`
);
}, []);
useEffect(() => {
setEffect("Child side effect");
console.log(
`Child useEffect 실행 \n effect: ${effect},\n layout: ${layout}`
);
}, []);
return <div>{effect}</div>;
};

- window, document에 이벤트 등록
- DOMContentLoaded 실행
- root.render 실행
- App → Child 순으로 이펙트 훅은 등록만 하고 나머진 읽어낸다.
- 다 읽은 잎 노드(Child)는 마운트, paint.
잎 노드: 아래로 더 내려갈 게 없는 최하층 노드. - 모든 paint가 끝나면 useLayoutEffect 실행(자식 → 부모)
- 모든 useLayoutEffect가 끝나면 useEffect 실행(자식 → 부모)
- useLayoutEffect에서 등록했던 setState들 적용(부모 → 자식)
- useEffect에서 등록했던 setState들 적용(부모 → 자식)
- load 이벤트 fire
useEffect | useLayoutEffect | |
---|---|---|
실행 방식 | 비동기 | 동기 |
용도 | 사이드 이펙트 | 레이아웃 사용 |
실행 시점 | 모두 paint → 모든 layout effect → 유동적으로 실행 |
모두 마운트 → 모두 paint → 실행 |
실행 순서 | (먼저 끝난)잎 노드 → ... → 부모 | (먼저 끝난)잎 노드 → ... → 부모 |
상태 변경 시점 | layout effect에서 등록한 상태 변경들 끝난 후 |
|
주의점 | 핵심만 작성 (남용 X) |
핵심만 작성 (남용 X) |
특징 | UI 변경 시 깜빡일 수 있음 | 렌더 지연 |
useEffect는 비동기 실행이라 타이밍이 유동적임을 염두합니다.
┖ useEffect로 UI 변경한다고 무조건 깜빡이진 않는다.
┖ 우선 작업 많으면 useEffect가 늦게 실행된다.
const Colorful = () => {
const [isMount, setIsMount] = useState(true);
useEffect(() => {
setIsMount(false);
}, []);
return (
<div className={isMount ? styles.red : styles.green}>안녕하세요</div>
);
};

useEffect로 UI 변경해도 문제 없이 작동할 때는 많습니다.
const Colorful = () => {
const [isMount, setIsMount] = useState(true);
useLayoutEffect(() => {
queueMicrotask(() => {
for (let i = 0; i < 20000; i++) {
console.log(i);
}
});
}, []);
useEffect(() => {
setIsMount(false);
}, []);
return <div className={isMount ? styles.red : styles.green}>안녕하세요</div>;
};

이렇게 useEffect로는 도저히 방법이 없을 때에만 useLayoutEffect를 씁니다.
const Colorful = () => {
const [isMount, setIsMount] = useState(true);
useLayoutEffect(() => {
for (let i = 0; i < 20000; i++) {
console.log(i);
}
}, []);
useLayoutEffect(() => {
setIsMount(false);
}, []);
return <div className={isMount ? styles.red : styles.green}>안녕하세요</div>;
};


렌더는 비동기 작업이므로 동기적 코드가 있으면 렌더를 무시하고 코드를 실행합니다.
mount → paint → useLayoutEffect → update, repaint
브라우저는 싱글 스레드이며 렌더링 과정에서 동기적 코드를 만나면 화면을 멈추고 코드 실행에 집중합니다.
비록 컴포넌트를 마운트하고 빨간색으로 초기 paint를 했어도 뒤에 동기적으로 처리할 코드들이 대기중이면 브라우저는 판단을 합니다.
→코드를 실행하자. O
화면에 컴포넌트를 띄우자. X
예를 들면 paint - composite 사이에 화면 색이 100만 번 바뀐다고 가정해봅시다. 일일이 렌더하면서 사용자에게 과정을 다 보여줄 필요가 있을까요? 차라리 최종 화면만 사용자에게 표시하는 게 합리적입니다.
이런 이유로 useLayoutEffect는 사용자에게 초기 컴포넌트를 숨깁니다.
- 리액트: 마운팅 시 모든 코드를 읽고 처리하느라 초기 로딩이 늦다.
- Next.js: 각 컴포넌트를 순회하면서 컴포넌트 이름으로 조회하여 서버에서 만들어둔 HTML을 빠르게 가져온다. 이후 다시 순회하면서 클라이언트 코드 실행.
5. useInsertionEffect
“useInsertionEffect는 리액트가 DOM을 바꾸기 전에 발생합니다. CSS-in-JS 라이브러리로 동적 CSS를 할당합니다.”
동적으로 CSS를 할당할 일은 많지 않을 뿐더러, 공식 문서에서도 사용에 신중을 기하라고 주의를 합니다. 렌더링을 지연시키기 때문입니다.
6. useRef
바닐라 JS로 DOM 노드를 조작할 땐 아래와 같이 작성합니다.
const button = document.querySelector("button");
button.addEventListener("click", ()=>{..});
가끔 리액트로도 엘리먼트를 가져올 일이 있습니다. (엘리먼트 크기를 잰다던지)
querySelector 대신 useRef를 쓰면 엘리먼트를 가져올 수 있습니다.
const target = useRef<HTMLDivElement>(null);
useEffect(() => {
// 'target.current' is possibly 'null'.
const width = target.current.offsetHeight;
// 정상 작동.
if(target.current){
const width = target.current.offsetHeight;
}
});
return (
<>
<div ref={target}>this is div box</div>
</>
);
useRef를 쓰고, 엘리먼트에 ref를 등록하면 됩니다.
ref는 null이었다가 코드 실행중, 마운트 전에 업데이트됩니다.(생명 주기 그림 참고)
타입스크립트 개발 코드에선 ref가 null로서 인식됩니다. (런타임 전에는 참조된 게 없기 때문) 그래서 if문 등으로 ref.current가 있다고 가정해야 경고가 안 뜹니다.
6-1. 다른 용도
useRef는 변수 담을 때에도 씁니다.
특징: 값을 바꾼다고 리렌더링 X, 리렌더링 때 값을 다시 할당 X.
원리: 내부적으로 참조값을 유지하는 객체를 쓰기 때문.
'리렌더링마다 할당하긴 좀 그런 변수.'에 씁니다. 이미 비용이 드는 값을 캐시하는 훅 useMemo가 있으므로 구분해서 씁시다.