Q20. React로 개발하다 보면 useEffect나 이벤트 핸들러에서 최신 State를 가져오지 못하는 경우를 겪습니다. 이것은 React의 버그가 아니라 자바스크립트 클로저(Lexical Scope)의 지극히 정상적인 동작입니다.
아래 코드에서 버튼을 3번 클릭하여 count가 3이 된 상태라고 가정합시다. 그 후 1초 뒤 log 함수가 실행되면 콘솔에는 몇이 찍힐까요?
function Counter() {
const [count, setCount] = useState(3); // 초기값 3
// [문제의 코드]
// 의존성 배열이 []라서, 이 효과는 '첫 렌더링' 때 딱 한 번만 실행됨
useEffect(() => {
const id = setInterval(() => {
// 이 함수는 "첫 렌더링 시점의 count(3)"을 기억(Capture)하고 있음
console.log("Timer Log:", count);
}, 1000);
return () => clearInterval(id);
}, []); // <--- 여기가 비어있는 게 원인!
// 화면에 보이는 숫자를 증가시키는 함수
const handleClick = () => {
setCount(prev => prev + 1); // 4, 5, 6... 으로 증가
};
return (
<div>
<h1>화면의 숫자: {count}</h1>
<button onClick={handleClick}>증가시키기 (+)</button>
</div>
);
}
[결과] 콘솔에는 계속 3만 출력됩니다.
[원인]
- 스냅샷과 클로저: useEffect 내부의 setInterval 콜백 함수는 컴포넌트가 최초 렌더링(Mount)될 때 생성되었습니다. 이 시점의 count 값은 3이었으며, 클로저는 이 환경(Lexical Environment)을 캡처(기억)합니다.
- 의존성 배열의 부재: useEffect의 의존성 배열이 빈 배열([])이므로, 이후 count가 아무리 변해도 이펙트(Effect)는 재실행되지 않습니다.
- 결과: 타이머 함수는 영원히 "나의 부모의 렉시컬 환경 count는 3이었다"는 사실만 기억하고 출력하게 됩니다.
[해결 방법 1: 의존성 배열 수정] count가 변경될 때마다 타이머를 새로 생성하여 최신 환경을 다시 캡처하도록, 의존성 배열에 [count]를 추가해야 합니다.
+ handleClick 또한 근본적으로는 클로저의 특성을 가집니다.
사용자가 버튼을 누르면 브라우저 Web API가 이를 감지하여 handleClick 작업을 TaskQueue에 넣고, 이후 Event Loop에 의해 Call Stack으로 이동하여 실행됩니다.
현재 코드에서는 handleClick을 메모이제이션(useCallback) 하지 않아서 count가 변경될때마다 handleClick을 새로 생성하기 때문에 항상 최신의 count를 가져올 수 있습니다. 하지만, useCallback을 사용하는데 count에 의존성 배열을 명시해주지 않는다면, 이는 문제를 발생시킵니다. 또 상태를 업데이트 할때 setCount(prev => prev +1)과 같은 함수형 업데이트를 사용하면 클로저가 기억하는 변수에 의존하지 않고, Reacr가 내부적으로 관리하는 최신 State 인자로 직접 주입받아 안전하게 사용할 수 있습니다.
Q21. Webpack이나 Vite 같은 번들러 환경, 혹은 Node.js 환경입니다. 우리는 api.js라는 파일에 API 호출 횟수를 세는 변수를 만들었습니다. 이것을 여러 파일에서 import 해서 쓸 때, 변수는 공유될까요, 리셋될까요?
(2)번 라인, 즉 doSomethingB()가 실행될 때 콘솔에 찍히는 Count 값은 무엇인가요?
// api.js
let callCount = 0; // 모듈 스코프 변수
export const fetchApi = () => {
callCount++;
console.log(`API Call Count: ${callCount}`);
};
// A.js
import { fetchApi } from './api.js';
export const doSomethingA = () => {
fetchApi(); // 호출!
};
// B.js
import { fetchApi } from './api.js';
export const doSomethingB = () => {
fetchApi(); // 호출!
};
// main.js (진입점)
import { doSomethingA } from './A.js';
import { doSomethingB } from './B.js';
doSomethingA(); // (1)
doSomethingB(); // (2)
실행결과는 모두 다 알거라고 생각합니다 :) 이부분은 왜 이렇게 되는지 이유를 아는 게 중요하다고 생각합니다.
이유를 중심으로 생각해보시면 좋을 것 같습니다.
최초 실행 후, 생성 된 모듈 인스턴스 (Export 된 값들과 스코프)를 메모리에 캐싱 해둡니다.
결국 A.js에서 부르든, B.js에서 부르든 심지어 100군데서 불러도, 자바스크립트는 파일을 다시 실행하지 않고 이미 메모리에 올라와 있는 캐시된 모듈 인스턴스를 건네 줍니다.
실무에서 axios 인스턴스를 하나 만들어서 export default 하고 여기저기서 가져다 써도 설정 (Header 등)이 유지되는 이유가 바로 이 모듈 캐싱 덕분입니다.
'CS 질문' 카테고리의 다른 글
| [Deep Dive CS - Q22] Prototype Chain (0) | 2025.12.17 |
|---|---|
| Deep Dive CS 질문 리스트 2 (0) | 2025.12.17 |
| [Deep Dive CS - Q19] 실행결과 (0) | 2025.12.17 |
| [Deep Dive CS - Q17,Q18] Hoisting & Execution Context (0) | 2025.12.17 |
| [Deep Dive CS - Q16] 화살표 함수를 지양해야 되는 상황 (1) | 2025.12.16 |