백엔드 속이는 프론트엔드 성능 최적화, ‘Optimistic UI’
사용자 경험을 한 단계 높이는 프론트엔드 패턴
성능 최적화의 길은 험난합니다. 백엔드 개발자는 "이 요청 처리가 왜 이렇게 느린 거야?!" 하며 밤새 디버깅합니다. 수직 확장, 수평 확장으로 서버를 늘리고, 레디스 캐시 레이어를 추가하고, DB 쿼리 최적화에 몇 주를 투자합니다. 적절한 샤딩과 파티셔닝을 위해 아키텍처 설계에 수백 시간을 쏟아붓고, 수십 명의 엔지니어가 투입된 프로젝트도 부지기수죠.
그런데 여러분은 "프론트엔드"를 왜 선택하셨나요?
이러한 지점에서 프론트엔드는 우위를 지닙니다. 사실 모든 웹과 앱은 백엔드 엔지니어의 노고 덕분에 돌아가지만, 결국 앞에 나서는 건 프론트엔드입니다. 마치 요리사가 요리를 잘 만들어도 서빙하는 사람이 손님에게 칭찬받는 것처럼요. 이런 점 때문에 저는 프론트엔드를 선택했습니다.
저도 처음엔 백엔드로 개발을 시작했다가 프론트엔드로 전향했습니다. "버튼 색깔만 바꿔도 '우와' 소리 듣는다"라는 말에 현혹된 거죠. 솔직히 말하면 저는 좀 게으릅니다. 최소한의 노력으로 사장님과 기획자들에게 감탄을 끌어내고 싶었어요. 유저들은 결국 보이는 것만 평가하니까요. 이런 프론트엔드 개발자에게 최고의 가성비 기술이 바로 ‘Optimistic UI’입니다. 단 몇 줄의 코드로 사용자를 기분 좋게 속이는 마법이죠.
INP: 구글도 인정한 상호작용의 중요성
구글의 'INP(Interaction to Next Paint)' 지표는 사용자 인터랙션 후 화면 업데이트 속도를 측정합니다. 2024년 3월부터 구글은 INP를 Core Web Vitals의 공식 지표로 채택했습니다. 이는 단순한 기술 지표가 아닙니다. 구글의 공식 웹 개발 블로그 web.dev에 따르면, RedBus는 INP 최적화로 점수를 72% 개선하고 매출을 7% 증가시켰으며, The Economic Times는 INP 개선으로 이탈률을 50% 감소시켰습니다.
'Optimistic UI' 패턴은 이 INP 지표에서 빛을 발하며 서버 응답 없이도 즉시 UI를 업데이트해 사용자에게 "빛의 속도" 같은 경험을 선사합니다. 페이스북의 좋아요 버튼, 슬랙의 메시지 전송 등 인기 서비스들이 이미 이 기법을 활용하고 있죠.
이력서에 "Optimistic UI 패턴 도입으로 사용자 체감 속도 200% 향상" 같은 문구를 적고 싶으신가요? 또는 면접에서 "저는 UX 향상을 위해 이런 패턴을 적용해 봤습니다"라고 말하고 싶으신가요?
이제 낙관적 업데이트가 무엇인지, 왜 중요한지, 그리고 어떻게 구현하는지 함께 알아보겠습니다.
Optimistic UI가 뭔데?
Optimistic UI는 백엔드에 요청을 보내는 즉시, 성공 여부를 기다리지 않고 화면을 업데이트하는 패턴입니다. 전통적인 UI 패턴에서는 "요청 → 대기 → 응답 → UI 업데이트" 순서로 진행되지만, 이 방식에서는 사용자가 항상 네트워크 지연을 체감할 수밖에 없습니다.
전통적인 UI 패턴:
사용자 → 액션 발생 → API 요청 → 로딩 표시 → 서버 처리 → 응답 수신 → UI 업데이트
반면 Optimistic UI는 서버 응답 성공을 낙관적으로 가정하고 UI를 즉시 업데이트합니다.

이런 패턴이 가능한 이유는 정상적인 상황에서 대부분의 요청이 성공하기 때문입니다. 또한 응답 결과가 예측 가능한 경우가 많습니다. 메시지를 보내면 내 메시지가 그대로 추가될 것이고, 좋아요를 누르면 내 좋아요가 피드에 추가될 것입니다. 이처럼 내가 작성한 리소스를 추가하는 경우에 특히 유용합니다.
반면, 내가 요청했을 때 랜덤한 숫자가 반환되거나 검사 진단표가 생성되는 등 결과를 예측할 수 없는 경우에는 이 패턴을 적용하기 어렵습니다.
디스코드에서도 쓴다!
디스코드는 이 패턴을 효과적으로 활용하는 좋은 사례입니다. 메시지를 전송하면 즉시 채팅창에 표시되지만, 서버 확인 전까지는 약간 흐릿하게 보입니다. 요청이 성공하면 텍스트가 선명해지고, 실패하면 오류 메시지와 함께 재시도 옵션을 제공합니다.

만약 디스코드가 이 패턴을 사용하지 않았다면? 메시지를 보내고 서버 응답을 기다리는 동안 아무 변화가 없거나, 로딩 스피너만 돌아가는 답답한 경험을 했을 것입니다. 특히 네트워크 상태가 좋지 않은 환경에서는 몇 초에서 몇 십 초까지 기다려야 할 수도 있습니다. 이런 상황에서 많은 사용자들은 "내 메시지가 전송됐나?" 하는 의문을 가지며 같은 메시지를 여러 번 보내는 실수를 하게 됩니다.
UI 관점에서도 Optimistic 상태는 구분이 필요합니다. 보통 약간 흐릿하게 표시하거나, 배경색을 다르게 하는 등의 방법으로 "아직 확정되지 않은 상태"임을 미묘하게 표현합니다. 이런 디테일이 사용자에게 시스템의 상태를 자연스럽게 전달하는 동시에, 즉각적인 반응성도 제공합니다.
인스타그램의 좋아요 버튼도 이와 같은 방식으로 구현되어 있습니다. 좋아요를 누르면 즉시 하트가 채워지고 숫자가 증가하며, 네트워크 상태와 무관하게 사용자에게 즉각적인 피드백을 제공합니다. 이렇게 프론트엔드 개발자는 백엔드의 개선이 지연되는 상황에서도 사용자에게 최적화된 경험을 미리 제공할 수 있습니다. 이것이 바로 프론트엔드 성능 최적화의 한 형태라고 볼 수 있습니다. 이제 어떤 조건에서 이 패턴을 적용할 수 있는지, 그리고 구체적인 구현 방법에 대해 알아보겠습니다.
Optimistic UI 구현을 위한 핵심 전략과 패턴
Optimistic UI를 효과적으로 구현하기 위해서는 몇 가지 핵심 전략과 패턴을 이해해야 합니다. 이 패턴의 가장 중요한 원칙은 원본 상태(서버 상태)와 낙관적 상태를 명확히 분리하여 필요시 롤백할 수 있는 기반을 마련하는 것입니다.
1. 원본 상태 백업과 낙관적 업데이트
Optimistic UI 구현의 첫 번째 단계는 항상 원본 상태를 백업해 두는 것입니다. 이는 마치 데이터베이스의 트랜잭션과 유사합니다. 요청이 실패할 경우 원래 상태로 되돌릴 수 있어야 합니다.
// 좋아요 버튼 구현 예시
function LikeButton({ postId, initialIsLiked }) {
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [isUpdating, setIsUpdating] = useState(false);
const handleLike = async () => {
// 원본 상태 백업
const originalState = isLiked;
// 낙관적 업데이트 (즉시 UI 변경)
setIsLiked(!isLiked);
setIsUpdating(true);
try {
// 백그라운드에서 API 요청
const response = await api.toggleLike(postId, !isLiked);
// 요청 성공, 업데이트 상태 종료
setIsUpdating(false);
} catch (error) {
// 요청 실패, 원본 상태로 롤백
setIsLiked(originalState);
setIsUpdating(false);
alert('좋아요 처리 중 오류가 발생했습니다.');
}
};
return (
<button
onClick={handleLike}
disabled={isUpdating}
className={`like-button ${isLiked ? 'active' : ''} ${isUpdating ? 'updating' : ''}`}
>
{isLiked ? '♥' : '♡'} 좋아요
</button>
);
}
이 예시에서 사용자가 좋아요 버튼을 클릭하면 UI는 즉시 업데이트되지만, 백그라운드에서 API 요청이 실패할 경우 원래 상태로 되돌립니다.
2. 리스트 내 항목 업데이트 처리
리스트에서 항목을 추가하거나 삭제할 때는 임시 ID를 사용하는 전략이 효과적입니다. 요청 전에 임시 ID로 항목을 생성하고, 서버 응답 후 실제 ID로 교체합니다.
function TodoList() {
const [todos, setTodos] = useState([]);
const [newTodoText, setNewTodoText] = useState('');
const handleAddTodo = async () => {
if (!newTodoText.trim()) return;
// 임시 ID 생성 (클라이언트에서만 사용)
const tempId = `temp-${Date.now()}`;
// 낙관적 업데이트로 임시 항목 추가
const tempTodo = {
id: tempId,
text: newTodoText,
completed: false,
isTemporary: true // 임시 항목 표시
};
setTodos([...todos, tempTodo]);
setNewTodoText('');
try {
// 백그라운드에서 API 요청
const response = await api.createTodo(newTodoText);
// 임시 ID를 실제 서버 ID로 교체
setTodos(prevTodos => prevTodos.map(todo =>
todo.id === tempId
? { ...response.data, isTemporary: false }
: todo
));
} catch (error) {
// 요청 실패, 임시 항목 제거
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== tempId));
alert('할 일 추가 중 오류가 발생했습니다.');
}
};
return (
<div>
<ul>
{todos.map(todo => (
<li key={todo.id} className={todo.isTemporary ? 'temporary' : ''}>
{todo.text}
</li>
))}
</ul>
<input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="새 할 일"
/>
<button onClick={handleAddTodo}>추가</button>
</div>
);
}
이 예시에서는 사용자가 새 할 일을 추가할 때 임시 ID로 항목을 즉시 표시하고, 서버 응답을 받으면 실제 ID로 업데이트합니다.
3. 중복 요청 및 경쟁 상태(Race Condition) 처리
사용자가 같은 항목에 빠르게 여러 번 액션을 취할 수 있습니다. 예를 들어, 좋아요 버튼을 빠르게 여러 번 누르는 경우가 있습니다. 이런 상황에서는 마지막 요청만 반영하거나, 요청 자체를 디바운스 처리하는 방법이 있습니다.
function LikeButtonWithDebounce({ postId, initialIsLiked }) {
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [pendingState, setPendingState] = useState(null);
// 디바운스 처리를 위한 useRef
const timerRef = useRef(null);
const latestRequestRef = useRef(null);
const handleLike = () => {
// 현재 의도한 상태 (토글)
const newLikedState = !isLiked;
// UI 즉시 업데이트
setIsLiked(newLikedState);
// 이전 타이머 취소
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 디바운스 적용 (300ms)
timerRef.current = setTimeout(async () => {
// 고유 요청 식별자 생성
const requestId = Date.now();
latestRequestRef.current = requestId;
setPendingState(newLikedState);
try {
const response = await api.setLikeStatus(postId, newLikedState);
// 최신 요청만 처리 (경쟁 상태 방지)
if (requestId === latestRequestRef.current) {
setPendingState(null);
}
} catch (error) {
// 최신 요청만 처리
if (requestId === latestRequestRef.current) {
// 실패 시 롤백
setIsLiked(!newLikedState);
setPendingState(null);
alert('좋아요 처리 중 오류가 발생했습니다.');
}
}
}, 300); // 300ms 디바운스
};
return (
<button
onClick={handleLike}
className={`like-button ${isLiked ? 'active' : ''} ${pendingState !== null ? 'pending' : ''}`}
>
{isLiked ? '♥' : '♡'} 좋아요
</button>
);
}
이 구현에서는 사용자가 좋아요 버튼을 빠르게 여러 번 누를 경우, 마지막 상태만 서버에 반영됩니다. 또한 요청 식별자를 사용해 경쟁 상태를 방지합니다. Optimistic UI는 이러한 패턴들을 효과적으로 조합하여 사용자에게 즉각적인 피드백을 제공하면서도, 데이터 정합성을 유지하는 방향으로 구현되어야 합니다.
리액트 팀도 Optimistic UI를 중요하게 생각합니다
리액트 팀은 Optimistic UI의 중요성을 인식하고 별도의 훅을 도입했습니다. React 19에서 추가된 `useOptimistic` 훅은 낙관적 업데이트를 선언적이고 안전하게 구현할 수 있게 해줍니다.
useOptimistic 훅의 기본 개념
`useOptimistic` 훅은 서버 상태와 낙관적 상태를 명확히 분리하여 관리합니다. 이 패턴의 아키텍처는 두 개의 레이어로 구성됩니다.
서버 요청 <-------> 리액트 상태(동기화)
|
|
v
옵티미스틱 상태(동기화)
첫 번째 레이어는 서버와 리액트 상태 간의 동기화를 담당하며, 두 번째 레이어는 리액트 상태와 낙관적 상태 간의 동기화를 담당합니다. 이렇게 분리함으로써 서버와의 통신 로직과 낙관적 UI 업데이트 로직을 완전히 분리할 수 있습니다.
리액트 팀이 이런 방식으로 설계한 이유를 추측해 보면, 이미 존재하는 서버와 클라이언트 상태 동기화 코드에 낙관적 업데이트 로직이 강하게 결합되지 않도록 하기 위함입니다. 이런 분리는 코드를 더 유연하게 만들고, 기존 코드를 크게 수정하지 않고도 낙관적 업데이트를 적용할 수 있게 해줍니다.
이 설계의 핵심은 원본 상태(서버 상태)가 변경되면 낙관적 상태도 자동으로 동기화된다는 점입니다. 동시에 사용자 액션에 따른 낙관적 업데이트를 서버 응답 전에 즉시 적용할 수 있습니다.
useOptimistic의 내부 구현 예상
단순히 생각하면 `useOptimistic`은 다음과 같이 구현될 수 있을 것 같습니다.
function useOptimisticSimple(state, updateFn) {
const [optimisticValue, setOptimisticValue] = useState(null);
// 서버 상태가 변경되면 낙관적 상태도 업데이트
useEffect(() => {
// state가 바뀌면 동기화 로직 실행
}, [state]);
const optimisticState =
optimisticValue !== null ? updateFn(state, optimisticValue) : state;
return [optimisticState, setOptimisticValue];
}
하지만 이러한 단순 구현에는 문제가 있습니다. 빠르게 반복되는 요청에서 낙관적 상태와 서버 상태 간의 동기화가 올바르게 작동하지 않을 수 있습니다.
예를 들어, 사용자가 빠르게 5개의 메시지를 보내는 시나리오를 생각해 보세요.
1. 사용자가 5개의 메시지를 연속으로 보냅니다.
2. 낙관적 상태는 즉시 5개 메시지가 모두; 들어있는 상태입니다.
3. 서버 응답이 순차적으로 도착합니다 (메시지 1, 메시지 2, ...)
4. 단순 구현에서는 서버 상태가 업데이트될 때마다 `useEffect`가 발동되어 낙관적 상태를 초기화하게 됩니다.
5. 결과적으로 UI는 메시지 1 → 메시지 1,2 → 메시지 1, 2, 3 → ... 식으로 뒤늦게 바뀌게 되어, 이미 UI에 표시된 낙관적 업데이트(메시지 4, 메시지 5)가 사라졌다가 나타나는 깜빡임이 발생합니다.
이는 사용자 경험을 저해하는 심각한 문제입니다.
리액트의 실제 구현
실제 ‘useOptimistic’ 훅은 이런 문제를 해결하기 위해 단순한 ‘useEffect’보다 훨씬 복잡한 구현을 갖고 있습니다. 리액트의 Fiber 아키텍처와 통합되어 서버 상태와 낙관적 상태의 업데이트를 정교하게 큐잉하고 병합합니다.
리액트 팀은 낙관적 업데이트와 서버 상태 업데이트 간의 관계를 추적하고, 서버 상태가 업데이트될 때 이미 적용된 낙관적 업데이트가 손실되지 않도록 합니다. 이를 통해 여러 개의 업데이트가 진행 중일 때도 UI가 일관되고 자연스럽게 유지됩니다.
이렇게 ‘useOptimistic’은 단순한 상태 관리 이상의 기능을 제공하며, 복잡한 비동기 상태 전환을 선언적으로 처리할 수 있게 해줍니다. 개발자는 낙관적 상태의 모양만 정의하면 되고, 복잡한 동기화 로직은 리액트가 내부적으로 처리합니다.

더 나은 프론트엔드 개발자로 가기
Optimistic UI는 강력한 기법이지만, 모든 상황에 적합한 것은 아닙니다. 결제, 계정 삭제, 중요 데이터 변경과 같은 중요한 작업에서는 사용자에게 정확한 진행 상황을 보여주는 것이 신뢰도를 높이는 방법입니다. 또한 파일 업로드나 복잡한 서버 처리와 같이 시간이 오래 걸리는 작업에서는 로딩 인디케이터를 표시하는 것이 더 적절할 수 있습니다.
그러나 좋아요, 댓글, 간단한 설정 변경 등 많은 일상적인 상호작용에서 낙관적 업데이트는 사용자 경험을 크게 향상시킵니다. 사용자는 버튼을 클릭하고 즉시 반응하는 인터페이스에 만족감을 느끼며, 이는 서비스에 대한 전반적인 인상을 좋게 만듭니다. 저는 회사 프로젝트에서도 이런 부분을 챙기려고 항상 노력하고 있습니다. 특히 사용자 인터랙션이 많은 기능에서는 낙관적 업데이트를 적용하여 체감 속도를 높이는 작업을 진행했고, 실제로 사용자 만족도 향상으로 이어졌습니다.
현대 웹 개발 생태계에서는 이러한 패턴을 쉽게 적용할 수 있도록 도와주는 도구들이 많이 등장했습니다. React Query(TanStack Query)와 같은 라이브러리들은 낙관적 업데이트 기능을 기본으로 지원하며, 복잡한 비동기 상태 관리를 단순화해 줍니다. 단순한 형태부터 시작하여 점차 복잡한 상황으로 확장해 나가면서, 기능 작동을 넘어 사용자 경험 개선을 고민하는 개발자로 성장할 수 있습니다. 그리고 이러한 패턴은 당신의 이력서와 포트폴리오에도 좋은 사례가 될 것입니다.
결국 프론트엔드 개발자로서 여러분은 "성능 최적화" 경험을 당당히 어필할 수 있게 됩니다. 백엔드의 실제 성능과 무관하게, 사용자는 여러분의 "트릭" 덕분에 빠른 동작을 경험할 테니까요.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.