React - useState의 함수형 업데이트와 직접 값 전달의 동작 원리

useState의 함수형 업데이트와 직접 값 전달의 동작 원리
1. 들어가며
리액트의 useState로 만든 setter 함수는 두 가지 형태의 인자를 받습니다. 숫자나 문자열 같은 값을 직접 넘길 수도 있고, 이전 값을 받아 새 값을 반환하는 함수를 넘길 수도 있습니다. 같은 setter인데 어떻게 두 종류의 인자를 모두 처리할 수 있는지, 그리고 두 방식이 실제로 어떻게 다르게 동작하는지를 정리한 문서입니다.
2. 자바스크립트는 오버로딩을 지원하지 않습니다
먼저 가장 큰 오해를 풀어야 합니다. 자바나 C++에서는 같은 이름의 함수를 매개변수 타입에 따라 여러 개 정의할 수 있습니다. 이걸 오버로딩이라고 부릅니다. 자바스크립트에는 오버로딩 문법이 없습니다. 같은 이름의 함수를 여러 번 정의해도 마지막 정의만 살아남고, 앞의 정의는 덮어써집니다.
그래서 setCount는 오버로딩된 함수가 아닙니다. 단 하나의 함수이고, 그 함수 내부에서 받은 인자가 무엇인지 검사해 분기 처리할 뿐입니다.
3. 타입스크립트의 타입 정의
타입스크립트로 보면 setter의 매개변수 타입은 유니언 타입으로 정의되어 있습니다.
type SetStateAction<S> = S | ((prevState: S) => S);
count가 number 타입이라면 setCount의 매개변수 타입은 number | ((prev: number) => number)가 됩니다. 숫자 하나를 받아도 되고, 숫자를 받아 숫자를 반환하는 함수를 받아도 된다는 뜻입니다. 이건 오버로딩이 아니라 한 함수가 여러 종류의 인자를 받을 수 있도록 타입을 열어둔 것입니다.
4. 두 가지 시점을 구분해야 합니다
setter의 동작을 이해하려면 두 시점을 명확히 분리해야 합니다.
첫 번째 시점은 setter를 호출하는 시점입니다. setCount(5)나 setCount(prev => prev + 1)을 적은 그 줄이 실행되는 순간입니다. 이 시점에는 state가 실제로 바뀌지 않습니다. 화면도 그대로입니다. 리액트는 그저 "이런 변경 요청이 들어왔다"는 정보를 큐(대기열)에 쌓아둡니다.
두 번째 시점은 리액트가 큐를 처리하는 시점입니다. 보통 이벤트 핸들러가 끝난 직후입니다. 이때 리액트는 큐에 모인 update들을 하나씩 꺼내 새 state 값을 계산합니다. 모든 계산이 끝나면 그 결과를 새 state로 삼아 컴포넌트를 다시 렌더링합니다.
대부분의 혼란은 이 두 시점을 섞어서 생각할 때 생깁니다. setter를 호출했다고 곧바로 state가 바뀌지 않는다는 점이 핵심입니다.
5. 큐에 무엇이 쌓이는지
setter에 무엇을 넘기느냐에 따라 큐에 들어가는 내용이 달라집니다.
setCount(5); // 큐: [{ action: 5 }] setCount(count + 1); // 호출 시점에 count + 1이 미리 계산됨 // count가 0이면 큐: [{ action: 1 }] setCount(prev => prev + 1); // 함수 자체가 그대로 큐에 들어감 // 큐: [{ action: prev => prev + 1 }]
직접 값을 넘기면 그 값이 큐에 박혀 있습니다. 값이 표현식이라면 호출 시점에 계산되어 결과 숫자가 박힙니다. 함수를 넘기면 함수 자체가 큐에 들어갑니다. 이 함수는 나중에 큐가 처리되는 시점에야 호출됩니다.
6. 큐를 처리하는 내부 코드
리액트 소스코드를 단순화하면 다음과 비슷한 형태입니다.
let baseState = currentState; for (const update of queue) { const action = update.action; if (typeof action === "function") { baseState = action(baseState); } else { baseState = action; } } return baseState;
baseState는 큐를 처리하는 동안 사용하는 임시 변수입니다. 현재 state 값으로 초기화한 뒤, update를 하나씩 꺼내 baseState를 갱신합니다.
핵심 분기는 typeof action === "function" 한 줄입니다. 자바스크립트에서 함수도 값이라서 typeof로 함수인지 구분할 수 있습니다. 함수면 baseState를 인자로 넣어 호출하고 반환값을 새 baseState로 삼습니다. 함수가 아니면 그 값 자체를 새 baseState로 만듭니다.
이게 setter가 두 종류의 인자를 다르게 처리하는 비밀입니다. 오버로딩이 아니라 typeof 검사 한 줄로 나뉩니다.
7. baseState는 매 단계마다 바뀝니다
여기서 자주 헷갈리는 지점이 있습니다. baseState는 큐를 처리하는 동안 매 단계마다 새 값으로 바뀝니다. setter를 호출할 때 바뀌는 게 아니라, 큐를 처리하는 시점에 바뀝니다.
baseState를 칠판 위의 숫자로 비유해봅시다. 처음에 칠판에 현재 state 값을 적습니다. 큐에는 칠판에 어떻게 새 숫자를 적을지 적힌 카드들이 순서대로 놓여 있습니다. 카드를 한 장씩 꺼내 칠판의 숫자를 지우고 새 숫자를 적습니다. 모든 카드를 처리하고 나면 마지막에 적힌 숫자가 새 state가 됩니다.
각 카드의 지시 내용이 다릅니다. "칠판에 5를 적어라"고 적힌 카드는 칠판의 현재 값을 무시하고 무조건 5를 적습니다. "칠판에 적힌 숫자에 1을 더해 적어라"고 적힌 카드는 칠판의 현재 값을 읽어 그것에 1을 더한 결과를 적습니다. 카드의 종류는 다르지만 카드를 처리할 때마다 칠판의 숫자가 바뀐다는 사실은 똑같습니다.
8. 직접 값 전달의 함정
이 동작을 이해하면 함수형 업데이트가 왜 필요한지가 분명해집니다. 다음 코드를 봅시다.
const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); }
count가 0인 상태에서 버튼을 한 번 누르면 어떻게 될까요. 기대는 count가 3이 되는 것이지만 실제로는 1이 됩니다.
setter를 호출하는 시점에 일어나는 일을 따라가봅시다. 첫 번째 setCount는 count + 1을 미리 계산합니다. count는 0이니 1이 됩니다. 큐에 {action: 1}이 들어갑니다. 두 번째 setCount도 같은 시점이라 count는 여전히 0입니다. 다시 1이 계산되어 큐에 {action: 1}이 들어갑니다. 세 번째도 마찬가지입니다.
큐: [ { action: 1 }, { action: 1 }, { action: 1 }, ]
이제 큐를 처리합니다. baseState는 0으로 시작합니다. 첫 번째 update에서 action이 숫자 1이므로 baseState는 1이 됩니다. 두 번째 update에서도 action이 숫자 1이므로 baseState는 또 1이 됩니다. 세 번째도 같습니다. 최종 baseState는 1입니다.
문제는 setter를 호출하는 시점에 count + 1이 미리 계산되어 큐에 박혀버린 데 있습니다. 그 시점에는 직전 setCount의 결과가 아직 반영되지 않았기 때문에 세 번 모두 같은 0을 기준으로 계산됩니다.
9. 함수형 업데이트의 동작
같은 코드를 함수형 업데이트로 바꿔봅시다.
function handleClick() { setCount(prev => prev + 1); setCount(prev => prev + 1); setCount(prev => prev + 1); }
이번엔 함수가 큐에 들어갑니다.
큐: [ { action: prev => prev + 1 }, { action: prev => prev + 1 }, { action: prev => prev + 1 }, ]
큐를 처리할 때 baseState는 0으로 시작합니다. 첫 번째 update의 action이 함수이므로 (prev => prev + 1)(0)을 호출해 1을 얻습니다. baseState는 1이 됩니다. 두 번째 update에서 (prev => prev + 1)(1)을 호출해 2를 얻습니다. baseState는 2가 됩니다. 세 번째에서 (prev => prev + 1)(2)를 호출해 3을 얻습니다. 최종 baseState는 3입니다.
핵심 차이는 값이 언제 계산되느냐입니다. 직접 값을 넘기면 setter를 호출하는 시점에 한 번 계산되어 그 값이 큐에 박힙니다. 함수를 넘기면 큐를 처리하는 시점에 그때그때 계산되어 직전 결과가 반영됩니다.
10. 두 방식을 섞어 쓸 때
두 방식을 섞어 써도 같은 원리로 동작합니다.
function handleClick() { setCount(count + 1); // count가 0이면 큐에 1이 들어감 setCount(prev => prev + 1); // 함수가 큐에 들어감 setCount(prev => prev + 1); // 함수가 큐에 들어감 }
큐: [ { action: 1 }, { action: prev => prev + 1 }, { action: prev => prev + 1 }, ]
큐를 처리하면 baseState는 0 → 1 → 2 → 3으로 가서 최종 3이 됩니다. 첫 번째 update가 baseState를 1로 만들고, 그 1을 두 번째 함수가 받아 2로 만들고, 다시 그 2를 세 번째 함수가 받아 3으로 만듭니다.
이 동작 덕분에 함수형 업데이트는 직전 update의 결과를 항상 정확하게 받을 수 있습니다.
11. 언제 함수형 업데이트를 써야 할까
함수형 업데이트는 직전 state 값을 기반으로 새 값을 계산할 때 안전합니다. 같은 이벤트 안에서 setter를 여러 번 호출하거나, 비동기 작업 후에 setter를 호출할 때, 또는 setInterval 같은 콜백 안에서 setter를 호출할 때 자주 필요합니다.
반면 새 값이 직전 state와 무관하다면 직접 값을 넘기는 편이 더 직관적입니다. 예를 들어 사용자가 입력한 텍스트로 state를 덮어쓰거나, API에서 받은 결과로 state를 통째로 교체할 때는 직접 값을 넘기면 됩니다.
12. 정리
setter는 자바나 C++의 오버로딩과 다릅니다. 자바스크립트는 오버로딩을 지원하지 않습니다. 대신 setter 내부에서 받은 인자가 함수인지 아닌지를 typeof로 검사해 분기 처리합니다. 타입스크립트는 이 동작을 표현하기 위해 매개변수 타입을 유니언 타입으로 정의해두었을 뿐, 실행 시점의 분기는 단순한 if문 한 줄로 일어납니다.
state가 바뀌는 시점은 setter를 호출하는 시점이 아니라 리액트가 큐를 처리하는 시점입니다. 이 시점에 baseState라는 임시 변수가 큐의 update를 하나씩 꺼내며 새 값으로 갱신됩니다. 직접 값을 넘기면 setter 호출 시점에 값이 미리 계산되어 큐에 박히고, 함수를 넘기면 큐 처리 시점에 직전 baseState를 인자로 받아 새 값을 계산합니다. 이 차이가 같은 이벤트 안에서 setter를 여러 번 호출했을 때 결과가 달라지는 이유입니다.




댓글
댓글을 작성하려면 이 필요합니다.