flushSync — 택배 묶음배송 vs 퀵서비스
기본 상황
React는 묶음배송 기사입니다. 코드에서 상태를 바꿔달라고 3번 요청해도, 기사는 바로 안 뜁니다. "지금 하는 일 다 끝나면 한 번에 몰아서 화면 갱신할게요" 하고 모아둡니다. 이게 배칭(batching) 이고, 대부분 이게 훨씬 효율적입니다.
문제는 내가 화면 갱신 결과를 지금 당장 봐야 할 때입니다.
setMessages([...messages, 새메시지]);
listRef.current.scrollTop = listRef.current.scrollHeight; // 스크롤 맨 아래로!
여기서 스크롤이 어긋납니다. 새 메시지는 아직 화면에 안 붙었고, scrollHeight는 새 메시지가 없던 시절의 높이를 알려주기 때문입니다. 배송이 아직 출발도 안 했는데 "물건 도착했지?" 하고 확인한 셈입니다.
flushSync는 여기에 퀵서비스를 부르는 것입니다.
import { flushSync } from 'react-dom';
flushSync(() => setMessages([...messages, 새메시지]));
// ↑ 이 줄이 끝난 시점엔 이미 화면에 붙어 있음
listRef.current.scrollTop = listRef.current.scrollHeight; // 정확함
언제 쓰냐, 딱 한 문장
"화면이 바뀐 뒤의 실제 크기·위치를 바로 다음 줄에서 읽어야 할 때"
- 새 메시지 추가 후 스크롤 맨 아래 고정
- 요소가 나타난 직후 그 요소에 커서 놓기
- 위치 바뀌기 전후 좌표를 재서 애니메이션 만들기 (FLIP 애니메이션)
- 인쇄 직전에 화면을 바꿔야 할 때
언제 안 쓰냐
로딩 스피너 켜기, 폼 입력값 저장, API 응답 반영 — 화면만 바뀌면 되는 일은 전부 그냥 두세요. 퀵서비스는 비쌉니다. 남발하면 React가 자랑하는 "부드럽게 나눠서 렌더링" 기능이 통째로 꺼집니다.
판단 순서 세 줄 요약
- 그냥
setState→ 99% 여기서 끝 - 화면 바뀐 뒤 뭔가 해야 함 →
useLayoutEffect - 그것도 안 되고, 한 함수 안에서 반드시 순서대로 처리돼야 함 →
flushSync
더 깊이: React 18부터 이야기가 달라진 이유
React 17까지는 배칭이 이벤트 핸들러 안에서만 동작했습니다. setTimeout, 프로미스 .then, 네이티브 이벤트 리스너 안에서의 setState는 각각 즉시 렌더링됐죠. 그래서 "비동기 콜백 안에서는 어차피 동기로 반영되니까" 하고 넘어가던 코드가 많았습니다.
React 18의 자동 배칭(automatic batching) 부터는 어디서 호출하든 전부 묶입니다. 옛날 코드가 React 18로 올라오면서 조용히 깨지는 지점이 바로 여기고, 그 탈출구로 공식 제공되는 것이 flushSync입니다. 즉 flushSync는 "새 기능"이라기보다 자동 배칭의 안전밸브입니다.
더 깊이: 쓰면 안 되는 위치가 있다
flushSync는 아무 데서나 부를 수 없습니다.
- 렌더링 도중 호출 → 에러. 렌더 함수 본문에서는 절대 금지
- useEffect / useLayoutEffect 내부 → 경고. 이펙트는 이미 렌더링 사이클의 일부라서 강제 flush가 모순됨
- 안전한 위치는 이벤트 핸들러, setTimeout 콜백, 웹소켓 메시지 핸들러 같은 "React 바깥에서 불려온 자리"뿐입니다
그리고 비용 문제 — flushSync는 해당 상태가 걸린 트리를 그 자리에서 동기로 다시 그립니다. 트리가 크면 그대로 프레임 드랍이고, startTransition으로 만들어 둔 우선순위 양보도 무시됩니다. "한 프레임 늦게 반영돼도 되는 일인가?"를 먼저 자문하고, 답이 예스면 쓰지 않는 게 맞습니다.
더 깊이: flushSync 없이 스크롤 하단 고정을 만드는 법
위 판단 순서 2번(useLayoutEffect)과 CSS만으로 해결되는 경우가 생각보다 많습니다.
// 방법 1: useLayoutEffect — 메시지가 바뀌면 그리기 직전에 스크롤
useLayoutEffect(() => {
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [messages]);
/* 방법 2: CSS 스크롤 앵커링 — 브라우저가 알아서 바닥에 붙여줌 */
.list { overflow-anchor: auto; }
.list::after { content: ''; overflow-anchor: auto; }
/* 방법 3: column-reverse 트릭 — 처음부터 바닥이 시작점 */
.list { display: flex; flex-direction: column-reverse; }
각각의 한계도 알아두면 좋습니다. 방법 1은 "내가 위로 스크롤해서 과거를 읽는 중"일 때도 바닥으로 끌어내리므로 조건 분기가 필요하고, 방법 2는 Safari 구버전에서 미지원이었고, 방법 3은 스크롤바 방향과 키보드 내비게이션이 반대가 되는 부작용이 있습니다. 이 한계들에 다 걸릴 때 비로소 flushSync 차례입니다.
한 줄 요약 — flushSync는 "React야, 이번만 묶음배송 말고 퀵으로 보내줘". 화면 갱신 직후의 크기·위치를 같은 함수 안에서 읽어야 할 때만 씁니다.
스스로 점검할 질문
flushSync를 안 쓰고 채팅 화면 스크롤 하단 고정을 구현하는 방법에는 어떤 것들이 있고, 각각 언제 깨지나요? (위의 세 가지 방법과 한계를 자기 언어로 설명할 수 있으면 통과)- React 17에서 잘 동작하던
setTimeout 안의 setState 후 DOM 읽기코드가 18에서 깨지는 이유는 무엇인가요?