React에서 소켓이나 SSE(Server-Sent Events) 같은 실시간 통신을 다루다 보면, 종종 이상한 상황을 마주하게 됩니다.
"상태를 바꿨는데 왜 안 바뀌지? 화면은 잘 나오는데… 이벤트 안에서는 옛날 상태네?"
👉 이런 경험 있으시죠?
저는 이번에 주식 도메인 프로젝트를 진행하며 해당 이슈를 겪게 되었습니다. 이 글에서는 이 문제의 원인과 해결 방법을 살펴보도록 할게요.
예를 들어, 주식 앱에서 특정 종목의 가격을 실시간으로 받아온다고 해볼게요.
실시간으로 들어오는 가격을 이전 상태(price
)와 비교해서, 가격이 바뀌었을 때만 상태를 업데이트하려는 코드가 있다고 해봅시다:
const [price, setPrice] = useState<number>(0);
useEvent(() => {
const eventSource = new EventSource('/api/sse/price');
eventSource.addEventListener('message', (event) => {
const livePrice = JSON.parse(event).price;
if (livePrice === price) return;
setPrice(livePrice);
});
return () => eventSource.close();
}
처음엔 이렇게만 해도 잘 동작하는 것처럼 보입니다.
그런데 시간이 지나면 이상한 일이 벌어지죠:
🔁 계속 같은 가격인데도 setPrice가 계속 호출돼요!
왜 이런 일이 발생할까요?
이벤트 핸들러는 컴포넌트가 처음 렌더링될 때 만들어진 함수입니다.
그리고 이 함수는 그 당시의 price
를 클로저로 기억해버립니다.
그래서 이후에 price
상태가 아무리 바뀌어도, 이 핸들러 안에서 참조하는 건 언제나 최초의 가격값이에요.
결국 이렇게 되는 거죠:
현재 price 상태값: 1000
클로저 속 price 상태값: 0
실시간 통신으로 받아온 가격: 1000
예상:
실시간 가격과 price가 동일해 setPrice가 호출되지 않음
결과:
if (1000 !== 0) → true → setPrice(1050) 호출됨
원인은 클로저에 과거 값이 갇혀서 조건문이 잘못 동작하게 되는 것입니다.
클로저란 함수와 함수가 선언된 어휘적 환경(lexical environment)의 조합
클로저를 이해하려면 먼저 자바스크립트의 Lexical Scoping (어휘적 스코핑), 즉 함수가 선언된 위치에 따라 스코프가 결정되는 방식을 이해해야 합니다.
클로저는 간단히 요약해 자신이 선언될 당시의 환경을 기억하는 함수입니다.
자바스크립트는 함수를 실행할 때마다 실행 컨텍스트 (Execution Context) 를 생성합니다.
이 실행 컨텍스트는 다음을 포함합니다:
즉, 함수가 선언될 때, “이 함수는 어떤 변수 환경을 참조하는가” 를 기억합니다.
이게 클로저의 핵심입니다.
function outer() {
let count = 0;
return function inner() {
console.log(count); // 클로저
};
}
const fn = outer(); // count가 선언된 환경이 캡처됨
fn(); // count는 여전히 살아 있음
위 예시에서 inner
함수는 outer
의 실행 컨텍스트가 사라진 뒤에도 count
를 참조할 수 있습니다.
왜냐하면 GC(Garbage Collector) 가 inner
가 count
를 참조하고 있으므로 그 환경(Variable Environment)을 해제하지 않고 유지하기 때문입니다.
📌 클로저는 "함수가 선언될 때, 자신의 상위 스코프를 참조로 기억하고, 해당 스코프가 살아남는 것"입니다.
클로저는 변수의 값을 복사하는 것이 아닌, 변수의 참조를 캡처합니다.
→ 즉, 클로저는 스냅샷이 아니라 '실시간 연결된 스코프'입니다.
React 함수 컴포넌트는 렌더링할 때마다 함수 전체를 다시 실행합니다. 하지만 useEffect
는 이와는 조금 다른 방식으로 동작합니다.
useEffect는 컴포넌트가 렌더링될 때 사이드 이펙트를 실행하기 위해 사용되는 훅입니다.
useEffect는 의존성 배열을 제공함으로써 특정 값의 변화에 따라 useEffect가 언제 실행되는지를 조절할 수 있습니다.
리액트는 이와 관련해서 의존성 배열 관련 린터를 억제하지 않기를 권장하고 있습니다.
React에서는 린터를 통해 useEffect
의 의존성 배열에 상태 값을 명시하길 권장합니다. 이는 클로저 트랩(Closure Trap) 을 방지하기 위한 중요한 규칙입니다.
🕳️ 클로저 트랩이란?
useEffect
내부에서 참조하는 값이 최신 상태가 아닌, 이전 렌더링 시점의 클로저에 캡처된 오래된 값(stale closure) 을 참조하는 문제입니다.
예를 들어 상태 값이 클로저에 저장되면, 해당 상태가 업데이트되더라도 클로저는 여전히 이전 값을 바라보게 됩니다.
React는 이 문제를 해결하기 위해 useEffect
의 의존성 배열을 활용합니다.
의존성 배열에 상태 값을 명시하면, 해당 값이 변경될 때마다 React는 새로운 클로저를 가진 effect 콜백을 다시 등록합니다.
즉, 최신 상태를 클로저 안에서 안전하게 사용할 수 있게 되는 거죠.
이 때문에, useEffect
의 의존성 배열에는 참조하는 모든 상태와 변수들을 반드시 명시해야 하는 것입니다.
price
를 의존성 배열에 넣으면 생기는 문제useEffect(() => {
const eventSource = new EventSource('/api/sse/price');
eventSource.addEventListener('message', (event) => {
const livePrice = JSON.parse(event.data).price;
if (livePrice === price) return;
setPrice(livePrice);
});
return () => eventSource.close();
}, [price]); // ❌ price가 변경될 때마다 SSE 해제 및 재연결
의존성 배열에 price
가 있으므로, 가격이 바뀔 때마다 이 Effect는 전부 다시 실행됩니다.
즉, 다음과 같은 흐름이 발생하죠:
price
→ useEffect
재실행eventSource.close()
)addEventListener
재등록결국 가격이 바뀔 때마다 SSE 연결이 새로 만들어지고, 서버와 클라이언트 모두 부담이 커집니다.
실시간 스트리밍의 핵심인 "지속적인 연결 유지"를 해치는 부작용이 생기는 거죠.
"실시간으로 받은 가격이 현재 상태(price)와 다를 때만 업데이트하고 싶다"
즉, 필요한 건:
price
를 이벤트 핸들러 안에서 참조하고 싶고이럴 땐 price
를 useRef
로 추적하거나 useEffectEvent
를 사용해야 해요.
useRef
로 최신 상태 추적하기useRef
는 리렌더링과 무관하게 유지되는 영속적인 객체를 만들어줍니다. 이 객체(ref.current
)는 컴포넌트가 리렌더링되어도 초기화되지 않고 계속 유지됩니다.
우리는 price
상태가 변경될 때마다 useRef
에 그 값을 복사해 넣어주기만 하면 됩니다.
이렇게 하면 이벤트 핸들러는 의존성 없이 priceRef.current
를 통해 언제나 최신 상태에 접근할 수 있어요:
const [price, setPrice] = useState<number>(0);
const priceRef = useRef(price);
useEffect(() => {
priceRef.current = price;
}, [price]);
useEvent(() => {
const eventSource = new EventSource('/api/sse/price');
eventSource.addEventListener('message', (event) => {
const livePrice = JSON.parse(event).price;
if (livePrice === priceRef.current) return;
setPrice(livePrice);
});
return () => eventSource.close();
}, []);
useRef
는 리렌더링마다 새로운 클로저에 캡처되지 않음useEffectEvent
로 깔끔하게React 공식에서 실험 중인 useEffectEvent
는 이 문제를 더 직관적이고 선언적으로 해결하기 위한 훅입니다.
useEffectEvent
로 선언된 함수는 항상 최신 상태 값을 참조하도록 React가 내부적으로 보장합니다. 즉:
useEffectEvent
내부 함수는 클로저에 오래된 값을 캡처하지 않음useRef
없이도 최신 상태를 안전하게 사용할 수 있음const [price, setPrice] = useState<number>(0);
const handleMessage = useEffectEvent((event: MessageEvent) => {
const livePrice = JSON.parse(event.data).price;
if (livePrice === price) return;
setPrice(livePrice);
});
useEffect(() => {
const eventSource = new EventSource('/api/sse/price');
eventSource.addEventListener('message', handleMessage);
return () => eventSource.close();
}, []);
useEffectEvent
는 다음과 같은 장점을 가짐:useRef
없이도 안전방법 | 특징 | 장점 | 단점 |
---|---|---|---|
useRef | 리렌더링에 무관한 참조 객체 | 신뢰할 수 있고 최신 버전에서 사용 가능 | current 접근 필요, 코드가 직관적이지 않음 |
useEffectEvent | 최신 상태를 자동으로 참조하는 함수 생성 | 선언적이고 깔끔함, 상태 추적 쉬움 | 아직 실험적, 사용에 주의 필요 |
저는 실시간 통신을 구현하며 “지속적인 연결 유지”를 위해, useEffect
의 의존성 배열에 상태 값을 넣지 않는 방법을 택했고, 그 과정에서 클로저 트랩이라는 예상치 못한 문제를 마주하게 되었습니다.
이 경험을 통해 깨달은 건 다음과 같습니다:
useEffect
내부에서 사용하는 값이 있다면, 그 값은 항상 최신 상태인지 의심해봐야 한다.실시간/비동기 이벤트 핸들러에서는 항상 최신 상태를 안전하게 사용할 수 있는 구조를 선택하자.
그리고 이를 위해 우리는 다음 중 하나를 선택할 수 있습니다:
useRef
: 상태를 별도로 추적하고 .current
로 접근useEffectEvent
: 선언형 방식으로 안전한 이벤트 핸들러 생성 (아직 실험적)이 경험을 통해 useEffect는 단순히 사이드 이펙트를 넣는 훅이 아니라, 의존성 배열을 통해 어떤 클로저가 실행될지 명확히 제어하는 도구라는 점을 이해할 수 있었어요.
React는 의존성 배열을 통해 "언제, 어떤 클로저를 실행할지" 제어하고 있었던 거죠.