🧩 React와 웹소켓 타이밍 이슈, Closure
MakeCandidate.tsx에서는 WebSocket 응답으로 받은 voteLimit 값을 기반으로 조건 분기(voteLimit !== null)하여 공(ball)을 생성한다.
하지만 예상과 달리 voteLimit이 null이 아님에도 null로 참조되어, 제한/무제한 여부가 잘못 반영되는 공이 생성되는 문제가 발생했다.
🔍 원인 분석
1. React 함수형 컴포넌트의 클로저 구조
WebSocketProvider 컴포넌트에서 Websocket 응답을 받고 setVoteLimit으로 voteLimit 상태를 업데이트 하고 있다.
아래 subscribeAll 함수 내부에서는 업데이트 된 voteLimit 값을 사용하고 싶었으나, 항상 null로 참조가 되었다.
// AS-IS
useEffect(() => {
// 이 내부는 렌더링 시점의 voteLimit을 "캡처"한 클로저
if (voteLimit !== null) {
console.log("voteLimit 있음!", voteLimit);
subscribeAll();
}
}, []);
이때 문제가 되는 건 다음과 같다:
- 초기 마운트 시 useEffect가 실행되는 시점에는 WebSocket 응답이 아직 도착하지 않아, voteLimit은 여전히 null인 상태다.
- 즉, 이 시점의 voteLimit은 "최신 상태"가 아니라, 초기 렌더링 당시의 상태(snapshot)이다.
이런 현상은 클로저가 초기 마운트 시점의 값을 "기억"하기 때문에 발생한다.
그렇다면 클로져는 언제, 어떻게 새로운 값을 기억하는가?
+ 함수의 실행 시점을 늦추기 위해서는 어떻게 해야할까?
React의 함수형 컴포넌트는 렌더링이 일어날 때마다 컴포넌트 전체 함수가 다시 호출되며,
그 안에서 정의된 모든 변수, 함수, useEffect 내부 클로저들도 새로 생성(정의)된다.
클로져에 값이 갖히지 않게 해야하고,
WebSocket 응답의 도착 이후에 subscribeAll()을 실행해야 한다.
즉, 함수의 실행 시점을 제어하고 그 함수 내부에서는 새로 갱신된 값을 참조할 수 있어야 한다.
✅ 해결 방법: 이벤트 기반으로 클로저 타이밍 문제 해결
registerListener()로 이벤트를 등록하고, 응답이 도착한 뒤에서야 콜백 함수에서 subscribeAll()을 호출하도록 하고 싶었다.
🔁 의도한 flow
1. 부모(WebSocketProvider) 렌더링
- setVoteLimit (아직 실행 안됨)
- registerListener(이벤트 타입별 콜백 등록 함수 정의)
2. 자식(MakeCandidate) 렌더링: 이벤트 등록만 해두기
- useEffect → registerListener("INITIAL_RESPONSE", ...)
3. WebSocket 응답 도착 (INITIAL_RESPONSE)
- setVoteLimit() → 상태 업데이트 → 리렌더 트리거
4. registerListener의 콜백 함수 호출
- 리렌더링 이후 이 시점의 클로저는 최신 voteLimit을 참조
- subscribeAll() 호출 (정상 실행)
// 이벤트 등록 함수
const registerListener = <T extends keyof typeof listenersRef.current>(type: T, fn: (payload?: any) => void) => {
listenersRef.current[type] = fn;
};
// 자식 컴포넌트 - 초기 마운트 시 subscribeAll 콜백함수 등록만 해두기
useEffect(() => {
registerListener("initialResponse", () => {
subscribeAll();
});
}, []);
// 부모 컴포넌트 - Websocket 응답 받기
const subscribeWebsocket = (client: Client) => {
client.subscribe(`/user/queue/${storedVoteUuid}/initialResponse`, (message: { body: string }) => {
const payload = JSON.parse(message.body);
setVoteLimit(JSON.parse(message.body).voteLimit);
listenersRef.current.initialResponse?.();
});
응답을 받은 뒤 setVoteLimit이 된 이후에야 자식 컴포넌트에서 초기 마운트 시 등록해두었던 이벤트 **실행**하기
};
하지만 이렇게 해도 voteLimit을 여전히 null로 참조하고 있었다.
registerListener는 사실 listenerRef라는 ref 값에 함수를 저장해두는 로직이고, 등록하는 것처럼 사용할 뿐이다.
useRef의 특징이 무엇인가?
리렌더링에 관여받지 않고 기존 참조를 유지하는 훅 아니었던가...
그래서 한번 등록된 registerListener("onMyVoteResult", handler)는
리렌더링과 무관하게 여전히 기존 함수 클로저를 유지했던 것이었다.
결과적으로, 바로 응답을 인자로 넘기는 방법을 선택했다.
subscribeAll 함수는 자식 컴포넌트에서 정의되어야 하는 함수이기 때문에
리스너 등록을 통해 하위에서 상위 컴포넌트의 동작을 간접 제어하는 것은 여전히 필요했다.
const subscribeWebsocket = (client: Client) => {
client.subscribe(`/user/queue/${storedVoteUuid}/initialResponse`, (message: { body: string }) => {
const payload = JSON.parse(message.body);
listenersRef.current.initialResponse?.(payload); // 바로 WebSocket 응답 넘기기
});
};
useEffect(() => {
registerListener("initialResponse", (payload: any) => {
subscribeAll(payload.voteLimit);
});
}, []);
🔁 결국 전체 흐름은 다음과 같다.
[초기 렌더링]
↓
자식 컴포넌트 useEffect → registerListener 등록 (subscribeAll 정의 포함)
↓
WebSocket 응답 도착 → voteLimit 수신
↓
onVoteLimitReceived(payload.voteLimit) 호출
↓
subscribeAll(payload.voteLimit) 실행 → 최신 voteLimit을 직접 사용하여 구독 진행
🧪 시행착오
1. useEffect의 의존성 배열에 voteLimit을 넣는다면?
useEffect(() => {
if (voteLimit !== null) {
subscribeAll();
}
}, [voteLimit]);
- WebSocket 응답이 도착해서 voteLimit이 변경되면 useEffect가 다시 실행됨
- 이 때는 voteLimit의 최신값을 참조할 수 있음
- 하지만... 한 번만 실행돼야 할 subscribeAll이 두 번 실행되어(구독 중복) 투표 옵션 BALL이 2개가 생겨버림
2. setTimeOut: setState 후 콜백 타이밍 맞추기
매크로 태스크 큐를 이용해 리렌더링 이후에 실행하도록 해보았다.
하지만 이전과 동일하게 갱신된 값이 아닌 여전히 null 값을 참조했다.
이 자세한 내용은 다음 블로그 포스팅 글로 따로 올릴 예정이다.
setVoteLimit(payload.voteLimit);
setTimeout(() => {
listenersRef.current.onVoteLimitReceived?.();
}, 0);
- setTimeout(() => ..., 0)은 매크로 태스크 큐에 들어간다. 그래서 React의 리렌더링 이후 실행될 수 있지 않을까?
하여 사용해봤다. 하지만 이 방법은 실패했다.
3. Promise, queueMicrotask
혹시 몰라 마이크로 태스트 큐도 실험해보았으나,
React의 내부 처리와 정확히 맞물리지 않아 여전히 이전 상태를 참조했다.
setVoteLimit(payload.voteLimit);
Promise.resolve().then(() => {
listenersRef.current.initialResponse?.(); // ❌ 예상과 달리 여전히 이전 값
});
// 또는
setVoteLimit(payload.voteLimit);
queueMicrotask(() => {
listenersRef.current.initialResponse?.(); // ❌ 예상과 달리 여전히 이전 값
});
🧠 배운점
- useEffect는 의존성에 있는 값이 변경되어야 재실행된다.
→ 상태는 업데이트되어도 의존성이 없다면 useEffect는 재실행되지 않음. - useEffect 내 이전 클로저에 갇힌 상태로 인해 stale value 문제가 발생할 수 있음.
- 이런 상황에선 listener + 리렌더 후 최신 클로저를 통한 실행 + payload를 직접 넘기기 방식이 유효하다.
- props처럼 상위 → 하위로 흐르는 게 기본이지만, 리스너 등록을 통해 하위에서 상위 컴포넌트의 동작을 간접 제어하는 것도 가능하다는 것
- 브라우저의 렌더링(페인팅)과 React의 렌더링은 다르다!