
이 포스팅은 Kent C. Dodds의 글을 재구성한 글입니다.
참고: https://www.epicreact.dev/how-react-uses-closures-to-avoid-bugs
Intro
본론에 앞서 위 영상을 시청해주시길 바랍니다. 이 글은 위 영상의 현상에 대한 이야기입니다!
첨부된 영상을 보시면 Post1에서 좋아요를 누른뒤 Post2로 이동하면 Post2의 좋아요가 unlike되는 것을 확인할 수 있습니다. 왜 Post1의 ❤️를 클릭했는데 Post2의 ❤️가 사라졌을까요? 이 문제는 리액트 class Component에서 흔하게 발생하던 이슈였습니다.
이번 글에서는 class component에서 발생했던 구조적 문제를 바탕으로 함수 컴포넌트에서 어떤 접근으로 문제를 해결했는지 알아보겠습니다.
클래스 컴포넌트에서 props는 가변적입니다.

props가 가변적이란 이야기는 부모 컴포넌트가 다시 렌더링되고 새로운 props가 자식 컴포넌트에 전달될 때 this.props가 새로 전달된 props객체로 변경된다는 뜻입니다. 이 부분은 리렌더링시 새로운 상태를 렌더링하기 위한 의도된 설계이며 당연한 결과입니다. 하지만 이 특징이 비동기 로직과 결합될 때 문제를 일으킬 수 있습니다.
예를 들어 비동기 작업을 시작한 후, 작업이 진행되는 동안 props가 변견되면 함수 호출 시점의 값이 아닌 최신 값을 사용하게 되는 문제가 발생합니다.
import * as React from 'react'
import {AppContext} from './provider'
import {canLike} from './utils'
import {PostView} from './post-view'
class Post extends React.Component {
static contextType = AppContext
handleLikeClick = async () => {
// 1️⃣ 이 시점에는 this.props.post가 "post1"입니다.
if (!(await canLike(this.props.post, this.context.user))) return
// 2️⃣ 이 시점에는 탭이 변경되어 this.props.post가 "post2"가 됐습니다.😵💫
this.context.toggleLike(this.props.post)
}
render() {
return (
<PostView post={this.props.post} onLikeClick={this.handleLikeClick} />
)
}
}
여기서 문제가 되는 부분은 canLike 함수입니다. canLike가 백그라운드에서 처리되는 동안 this.props.post가 업데이트 되면서 canLike의 호출시점의 post("post1")과 toggleLike 호출 시점의 post("post2")가 달라지게 됩니다.
이 문제는 toggleLike의 실행시점에 최신 props를 참조하면서 발생하는 문제로 다음의 방법으로 해결 가능합니다.
handleLikeClick = async () => {
// 🟢 구조분해할당을 통해 최신 props을 참조하는 문제를 해결
const { post } = this.props
// 🟢 함수 정의가 호출 시점과 실행시점에 달라질 수 있기 때문에 구조분해 할당
const { user, toggleLike } = this.context
if (!(await canLike(post, user))) return
toggleLike(post)
}
위 코드에서 this.context.toggleLike도 구조분해 할당을 한 것을 볼 수 있습니다. 단순히 관례적 표현이 아닌 this.props.post와 마찬가지로 호출 시점과 실행 시점에 정의가 달라지는 상황을 방어하기 위한 코드입니다. 당장 해결했지만 치명적일 수 있는 이슈를 관례에 의존해야 한다는 부분이 찝찝한 부분이긴 합니다. 구조적 문제가 남아있는한 개발자가 잊지 않고 구조분해 할당을 하길 기대할 수 밖에 없습니다. 즉, 프레임워크가 아니라 개발자의 습관에 문제 예방을 의존하는 구조입니다. 이 문제를 관례에 의존하지 않고 기본 설정으로 인적 실수를 원천 차단하는 시스템적 개선을 할 수 있을까요? 그게 바로 리액트 훅입니다.
함수 컴포넌트는 렌더 시점의 props를 기억합니다 (클로저)
import * as React from 'react'
import {useApp} from './provider'
import {canLike} from './utils'
import {PostView} from './post-view'
function Post(props) {
const {user, toggleLike} = useApp()
async function handleLikeClick() {
if (!(await canLike(props.post, user))) return
toggleLike(props.post)
}
return <PostView post={props.post} onLikeClick={handleLikeClick} />
}
export {Post}
훅에서는 props.post처럼 구조분해할당없이 바로 참조한 것을 볼 수 있습니다. 여기서는 문제가 안 됩니다. 왜냐면 컴포넌트 렌더링(컴포넌트 함수 호출)될 때의 props는 절대 변하지 않으니까요. 바꿔말하면 리렌더링이 발생할 때 새로운 props객체가 생성되어 함수 컴포넌트에 전달됩니다. 그래서 이전 렌더링에 호출된 함수가 다음 렌더링에 생성된 props객체를 참조할 가능성이 원천적으로 차단되는 겁니다. 함수가 실행될 때 실행시 전달받은 props을 기억하는 것, 그것이 바로 클로저입니다.
앞서 설명한 class 컴포넌트의 버그의 이해를 돕기 위해 클래스 컴포넌트의 상황을 함수 컴포넌트로 시뮬레이션해보겠습니다.
import * as React from 'react'
import {useApp} from './provider'
import {canLike} from './utils'
import {PostView} from './post-view'
function Post(props) {
const {user, toggleLike} = useApp()
const postRef = React.useRef(props.post)
React.useEffect(() => {
postRef.current = props.post
})
async function handleLikeClick() {
// 1️⃣ 호출시점 propsRef.current는 "post1"
if (!(await canLike(postRef.current, user))) return
// 2️⃣ 실행 시점 propsRef.current는 "post2"
toggleLike(postRef.current)
}
return <PostView post={props.post} onLikeClick={handleLikeClick} />
}
export {Post}
ref를 통해서 이전 렌더링에서 실행된 함수가 최신 렌더링의 props을 참조하는 상황을 연출해 봤습니다. 이제 class 컴포넌트에서 왜 그런 버그가 발생했는지 보다 직관적으로 이해할 수 있게 됐네요. toggleLike가 실행될 때의 props와 handleLikeClick이 호출될 때의 props가 달라졌습니다. 이런 문제를 해결하기 위해 함수 컴포넌트에서는 클로저를 통해 렌더 시점의 props를 고정했구요.
지금까지 렌더 시점의 props를 고정하는 것이 잠재적 문제를 해결하는 방향이었습니다.
하지만 모든 문제에서 이 접근이 정답은 아닙니다. 의도적으로 최신 값을 참조해야 하는 상황도 존재합니다. 대표적으로 debounce로직이 그렇습니다.
function debounce(fn, delay) {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
}, delay)
}
}
기본적인 debounce의 구현입니다. 그리고 이 함수를 이용해 훅을 만들어 보겠습니다. 렌더링 될 때 마다 새로운 디바운스를 생성하는 것을 방지하기 위해 useMemo를 사용했습니다.
function useDebounce(callback, delay) {
return React.useMemo(() => debounce(callback, delay), [callback, delay])
}
지금의 구현에는 문제가 있습니다.
useDebounce를 사용하는 곳에서 아래 코드처럼 메모이제이션을 하지 않으면 새로운 디바운스 함수가 생성되면서 debouce가 올바르게 동작하지 않습니다. 이는 좋은 api 설계라 볼 수 없습니다. useDebounce의 구현상의 문제를 호출하는 로직에 위임하게 됩니다. 그리고 useCallback의 의존성 배열도 신경써야 하네요. 문제를 해결한 것이 아니라 문제를 호출하는 쪽으로 넘긴 것이 됩니다.
const myFn = React.useCallback(() => {
// do debounced stuff
}, [vars, i, need])
const debouncedMyFn = useDebounce(myFn, 500)
호출부의 콜백 정의 방식을 신경쓰지 않고 useDebounce의 기능을 보장할 수 있는 방법이 필요합니다.
이런 상황에서는 useRef를 사용해 최신 값을 참조하는 것이 도움이 됩니다.
function useDebounce(callback, delay) {
const callbackRef = React.useRef(callback)
// Why useLayoutEffect? -> https://kcd.im/uselayouteffect
React.useLayoutEffect(() => {
callbackRef.current = callback
})
return React.useMemo(
() => debounce((...args) => callbackRef.current(...args), delay),
[delay],
)
}
이제 호출부에서 콜백의 정의를 신경써야 하는 문제가 완벽하게 해결됐습니다.
const myFn = () => {
// 디바운스가 필요한 로직
}
const debouncedMyFn = useDebounce(myFn, 500)
마무리
리액트 컴포넌트의 패러다임이 변화하면서 과거에 어떤 문제가 있었고 함수 컴포넌트에서는 어떤 방식으로 접근했는지 알아봤습니다. 한가지 유용한 통찰은 debounce 예제에서 봤던 것처럼 최신값을 유지하는 과거의 접근 방식이 현대에도 필요할 수 있다는 점입니다. 이분법적 사고가 아닌 상황과 시대에 맞는 더 경제적인 방법을 선택하고 상황에 따라 과거의 접근 방식을 검토해볼 수 있는 사고의 유연함이 중요한 것 같습니다.
참고
https://www.epicreact.dev/how-react-uses-closures-to-avoid-bugs
How React Uses Closures to Avoid Bugs
The sneaky, surreptitious bug that React saved us from by using closures.
www.epicreact.dev
'react' 카테고리의 다른 글
| 왜 내 코드의 Effect가 실행됐을까? (0) | 2025.12.29 |
|---|---|
| 함수 컴포넌트 시대에 돌아보는 class 문법 (0) | 2025.12.24 |
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |
| useEffect, 의존성 배열 lint를 무시하면 안되는 이유 (0) | 2025.12.19 |
| useReducer를 이용한 상태 모델링의 이점 (0) | 2025.12.19 |