September 15, 2025
포커스 관리는 문제가 생기기 전까지는 잘 인지하지 못하는 부분입니다. 하지만 한 번이라도 문제가 발생하면 앱이 어색하게 동작하거나, 접근성이 떨어지거나, 혹은 아예 잘못된 것처럼 느껴질 수 있습니다. 오늘은 포커스 관리를 제대로 할 수 있게 도와주지만, 잘 알려지지 않은 리액트 API인 flushSync
에 대해 말씀드리겠습니다.
리액트는 DOM을 빠르고 똑똑하게 업데이트합니다. setShow(true)
와 같은 상태 업데이트 함수를 호출하면 리액트는 즉시 리렌더링하지 않고, 이벤트 핸들러가 끝난 뒤 한 번에 상태 업데이트를 처리합니다. 성능상으로는 좋지만, 상태 변경 직후에 DOM과 상호작용해야 할 때 문제가 생길 수 있습니다.
예를 들어 보겠습니다.
function MyComponent() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>Show</button>
{show ? <input /> : null}
</div>
);
}
여기서 인풋이 나타나자마자 포커스를 주고 싶다고 해봅시다. 아마 아래와 같이 작성할 겁니다.
function MyComponent() {
const inputRef = useRef < HTMLInputElement > null;
const [show, setShow] = useState(false);
return (
<div>
<button
onClick={() => {
setShow(true);
inputRef.current?.focus(); // 아마 작동하지 않을 겁니다!
}}
>
Show
</button>
{show ? <input ref={inputRef} /> : null}
</div>
);
}
하지만 이건 동작하지 않습니다! 이유는 setShow(true)
를 호출했을 때 리액트는 업데이트를 예약할 뿐, 핸들러가 끝나기 전까지는 적용하지 않기 때문입니다. 따라서 인풋이 DOM에 존재하기도 전에 포커스를 시도하게 됩니다.
저 역시 경력 초창기에는 포커스 호출을 setTimeout
이나 requestAnimationFrame
으로 감싸서 해결하려 했습니다. 가끔은 잘 되지만, 가끔은 안 됐습니다. 브라우저나 기기, 앱의 다른 동작 상황에 따라 달라졌습니다. 따라서 이 방법은 신뢰할 수 없었고 임시방편에 불과했습니다.
onClick={() => {
setShow(true)
setTimeout(() => {
inputRef.current?.focus()
}, 10) // 🤞
}}
하지만 이건 그냥 운에 맡기는 겁니다. 매직 넘버에 의존하거나 브라우저가 빨리 업데이트해 주길 기대하는 건 좋은 방법이 아닙니다. 우리는 확실히 보장된 방법이 필요합니다.
flushSync
의 등장react-dom
의 flushSync
는 이런 상황을 위한 탈출구입니다. 리액트에게 “성능을 위해 배치 업데이트를 하는 걸 알지만, 이번 업데이트는 지금 당장 처리해야 해”라고 알려주는 역할을 합니다. flushSync
콜백 안에서 발생한 상태 업데이트는 즉시 적용되어, 콜백이 끝날 때 DOM이 최신 상태가 됩니다.
예시는 다음과 같습니다.
import { flushSync } from 'react-dom';
function MyComponent() {
const inputRef = useRef<HTMLInputElement>(null);
const [show, setShow] = useState(false);
return (
<div>
<button
onClick={() => {
flushSync(() => {
setShow(true);
});
inputRef.current?.focus();
}}
>
Show
</button>
{show ? <input ref={inputRef} /> : null}
</div>
);
}
이제 버튼을 클릭하면 인풋이 즉시 나타나고 자동으로 포커스를 받습니다. 더 이상 임시방편도, 운에 맡기는 일도 없습니다. 안정적인 포커스 관리가 가능해집니다.
Epic React 고급 리액트 API 워크숍의 예시를 보겠습니다. Ryan Florence님께서 제공해 주신 컴포넌트인데요, <EditableText />
라는 컴포넌트는 사용자가 텍스트를 인라인으로 수정할 수 있게 해 줍니다. 버튼을 누르면 인풋으로 바뀌고, 제출하거나 블러 되거나 ESC 키를 누르면 다시 버튼으로 돌아갑니다. 우리가 원하는 동작은 다음과 같습니다.
구현은 다음과 같습니다.
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
function EditableText({
initialValue = '',
fieldName,
inputLabel,
buttonLabel,
}: {
initialValue?: string;
fieldName: string;
inputLabel: string;
buttonLabel: string;
}) {
const [edit, setEdit] = useState(false);
const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
return edit ? (
<form
onSubmit={event => {
event.preventDefault();
flushSync(() => {
setValue(inputRef.current?.value ?? '');
setEdit(false);
});
buttonRef.current?.focus();
}}
>
<input
required
ref={inputRef}
type="text"
aria-label={inputLabel}
name={fieldName}
defaultValue={value}
onKeyDown={event => {
if (event.key === 'Escape') {
flushSync(() => {
setEdit(false);
});
buttonRef.current?.focus();
}
}}
onBlur={event => {
flushSync(() => {
setValue(event.currentTarget.value);
setEdit(false);
});
buttonRef.current?.focus();
}}
/>
</form>
) : (
<button
aria-label={buttonLabel}
ref={buttonRef}
type="button"
onClick={() => {
flushSync(() => {
setEdit(true);
});
inputRef.current?.select();
}}
>
{value || 'Edit'}
</button>
);
}
이 접근법은 사용자가 얼마나 빠르게 UI와 상호작용하든, 포커스가 항상 기대한 대로 정확하게 유지되도록 보장합니다.
왜 이렇게까지 포커스를 신경 써야 할까요? 이유는 키보드 접근성이 모든 사람에게 중요하기 때문입니다. 장애로 인해 키보드에 의존하하는 사람들도 있지만, 단순히 키보드를 선호하는 많은 파워 유저(저와 아마 이 글을 읽는 분들 포함)들도 존재 합니다. 포커스 관리가 깨지면 키보드 내비게이션이 답답하거나 불가능해집니다. 올바른 포커스 관리는 다음과 같은 경험을 제공합니다.
이런 디테일이 앱을 모든 사용자에게 즐겁게 만드는 요소입니다.
flushSync
를 언제 사용해야 할까요?onbeforeprint
)하지만 주의하셔야 합니다! flushSync
는 성능 최적화 측면에서 손해를 보는 선택입니다. 꼭 필요한 경우에만, 정말 즉각적인 DOM 업데이트가 필요할 때만 사용해야 합니다. 대부분의 경우 리액트의 기본 비동기 업데이트 방식으로 충분합니다.
포커스 관리는 미묘하지만 접근 가능하고 즐거운 리액트 앱을 만드는 데 매우 중요한 부분입니다. flushSync
는 리액트의 일반적인 업데이트 흐름에서 벗어나 지금 당장 무언가를 실행해야 할 때 사용할 수 있는 강력한 도구입니다. 현명하게 사용하시면, 사용자는 확실히 더 나은 경험을 하게 될 것입니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!