롤백을 빼먹고 낙관적 업데이트를 키면 안된다는 걸 깨닫고 좋아요 버튼 하나에서 시작된 문제로 낙관적 업데이트의 핵심을 정리한 글입니다.

응답을 기다리지 않고 먼저 그리는 이유
좋아요 버튼을 누르면 하트가 채워지고 숫자가 하나 올라갑니다. 이 반응이 즉각적일수록 앱은 빠르고 매끄럽게 느껴지죠. 그런데 실제로 그 숫자를 바꾸려면 서버에 요청을 보내고, DB에 기록되고, 응답이 돌아와야 합니다. 네트워크가 조금만 느려도 여기에 수백 밀리초가 걸립니다. 서버가 "네, 반영했습니다"라고 확정해 줄 때까지 하트가 멍하니 굳어 있으면, 사용자는 버튼이 고장 났나 하고 한 번 더 누르게 되죠.
이 대기 시간을 감추는 기법이 낙관적 업데이트(optimistic update)입니다. 핵심은 이름 그대로입니다. "요청은 어차피 성공할 것"이라고 낙관하고, 서버 응답을 기다리지 않은 채 UI부터 먼저 바꿔 버리는 것이죠. 좋아요를 누른 그 순간에 화면의 하트를 즉시 채우고 숫자를 128로 올려 둡니다. 서버 확정은 그 뒤에서 조용히 처리됩니다. 반대로 서버 응답을 다 받은 다음에야 화면을 바꾸는 방식을 비관적 업데이트라고 부릅니다.
여기서 짚어 둘 점은, 낙관적 업데이트가 실제로 무언가를 빠르게 만드는 게 아니라는 것입니다. 서버 왕복 시간은 그대로예요. 다만 그 시간을 사용자 눈에 보이지 않는 뒤편으로 옮겨 둘 뿐이죠. 즉 이건 성능 최적화라기보다 체감 성능을 다루는 심리적 기법에 가깝습니다. 그래서 어울리는 자리와 안 어울리는 자리가 분명히 갈립니다. 실패 확률이 낮고, 설령 틀려도 되돌리기 쉬운 단순한 상태 변화일수록 잘 맞죠.

두 방식의 체감 차이는 위 그림에 그대로 드러납니다. 비관적 방식은 클릭과 화면 변경 사이에 왕복 지연이 고스란히 노출되지만, 낙관적 방식은 그 구간을 0에 가깝게 만들죠. 좋아요·팔로우·즐겨찾기 토글처럼 거의 항상 성공하고, 실패해도 피해가 크지 않은 동작에서 특히 잘 어울립니다. 반대로 결제 완료나 계좌 잔액처럼 틀리면 사용자가 실제로 손해를 보는 화면이라면, 저는 이 기법을 쓰지 않거나 아주 조심스럽게만 씁니다. 잘못된 낙관은 잠깐의 매끄러움보다 훨씬 큰 신뢰 문제를 만들 수 있으니까요.
저는 사이드 프로젝트로 만든 피드 앱에서 좋아요 버튼에 이걸 처음 적용했습니다. 적용 전에는 좋아요를 누르고 하트가 채워지기까지 대략 300ms 정도 빈 시간이 있었는데, 낙관적 업데이트를 켠 뒤로는 그 지연이 사실상 사라져 손끝에 착 붙는 느낌이 났죠. 체감 반응이 눈에 띄게 달라져서 꽤 뿌듯했습니다. 문제는 그 뿌듯함이 딱 하루짜리였다는 점이었죠.
롤백이 빠지면 무슨 일이 생기는가
제가 처음 짠 코드는 아주 단순했습니다. 버튼을 누르면 로컬 상태의 좋아요 수를 먼저 1 올리고, 그다음 서버로 POST 요청을 보내는 게 전부였죠. 대략 이런 모양이었습니다.
async function toggleLike(postId) {
setLikes((n) => n + 1); // UI 먼저 반영
await api.post(`/posts/${postId}/like`); // 성공만 가정
}
로컬에서는 완벽했습니다. 서버가 늘 200을 돌려줬으니까요. 그런데 배포하고 지하철에서 앱을 켜 본 다음 날, 이상한 장면을 봤습니다. 좋아요를 눌러 숫자가 128로 올라갔는데, 화면을 새로고침하니 127로 되돌아가 있었던 겁니다. 분명히 눌렀는데 반영이 안 된 것이죠. 네트워크 탭을 열어 보니 그 요청은 지하철 터널 구간에서 타임아웃으로 실패해 있었습니다. 처음엔 서버 문제인가 싶어 로그부터 뒤졌는데, 정작 서버 로그에는 그 요청이 도착한 기록조차 없었습니다. 요청이 아예 서버까지 못 간 것이었죠.
원인은 명백했습니다. await 뒤의 요청이 실패하면 예외가 던져지는데, 저는 setLikes를 되돌리는 코드를 어디에도 두지 않았습니다. 그러니 요청이 실패해도 화면의 숫자는 이미 올라간 채로 남았죠. 서버는 127인데 화면만 128인, 전형적인 상태 불일치였습니다. 콘솔에는 POST /posts/42/like 요청 하나가 빨간 글씨로 net::ERR_TIMED_OUT을 뱉고 있었지만, 화면은 그 실패를 전혀 모른 채 태연하게 128을 보여 주고 있었습니다.
더 고약한 건 이게 눈에 잘 안 띄는 버그라는 점이었습니다. 화면만 보면 좋아요가 성공한 것처럼 멀쩡해 보이니까요. 사용자는 새로고침을 하거나 다시 들어왔을 때에야 "어? 아까 누른 게 풀렸네" 하고 혼란스러워합니다. 재현조차 까다로웠습니다. 회사 와이파이에서는 요청이 실패할 일이 거의 없어서, 네트워크를 인위적으로 offline으로 막고 나서야 버그를 눈으로 확인할 수 있었죠. 낙관적 업데이트에서 롤백을 빼먹는 건, 말하자면 "실패라는 경우의 수를 코드에서 통째로 지워 버린" 것과 같았습니다.
안전한 낙관적 업데이트의 조건
교훈은 분명했습니다. 낙관적 업데이트의 진짜 절반은 화면을 미리 바꾸는 게 아니라, 틀렸을 때 되돌리는 것에 있었죠. 고친 코드의 뼈대는 세 단계로 정리됩니다.
- 이전 상태 스냅샷 저장 — 화면을 바꾸기 전에 지금 값을 따로 기억해 둡니다.
- 실패 시 롤백 —
catch에서 저장해 둔 스냅샷으로 상태를 되돌립니다. - 서버 응답으로 최종 동기화 — 성공하면 서버가 준 확정값으로 맞춰 둡니다.
async function toggleLike(postId) {
const prev = likes; // ① 이전 값 스냅샷
setLikes((n) => n + 1); // ② 낙관적으로 먼저 반영
try {
const { count } = await api.post(`/posts/${postId}/like`);
setLikes(count); // ③ 서버 확정값으로 동기화
} catch (e) {
setLikes(prev); // 실패 → 스냅샷으로 롤백
toast("좋아요를 반영하지 못했어요");
}
}
세 번째 단계인 서버 동기화를 굳이 넣는 이유도 있습니다. 낙관적으로 올린 값과 서버가 실제로 확정한 값이 항상 같지는 않기 때문이죠. 예를 들어 다른 사람이 같은 글에 동시에 좋아요를 눌렀다면, 내 화면은 128을 예상했지만 서버는 129를 돌려줄 수 있습니다. 그래서 성공했더라도 응답으로 온 최종 count에 화면을 다시 맞춰 줘야 어긋남이 남지 않죠. "실패면 되돌리고, 성공이면 확정값으로 덮는다"—이 두 방향을 모두 닫아 두는 게 핵심입니다.
여기서 한 가지 더 챙겨야 할 게 동시성입니다. 사용자가 좋아요를 빠르게 연타하면 요청이 여러 개 겹치는데, 늦게 보낸 응답이 먼저 도착하면 숫자가 엉킬 수 있죠. 그래서 저는 요청이 처리되는 동안 버튼을 잠깐 잠그거나, 마지막 요청만 유효하게 두는 방식으로 mutation을 직렬화했습니다. 지금은 이런 처리를 직접 짜기보다 React Query 같은 라이브러리의 onMutate·onError·onSettled 훅에 맡기는 편인데, 스냅샷을 반환하고 롤백을 강제하는 구조라서 저 같은 실수를 애초에 막아 주더군요. 처음 이 흐름을 직접 구현해 본 경험이 있으니 라이브러리가 왜 그렇게 생겼는지도 훨씬 잘 이해되었습니다.

정리하면 이렇습니다. 낙관적 업데이트는 "성공을 미리 그리는" 기법이 아니라, "성공을 미리 그리되 실패를 반드시 되돌리는" 기법입니다. 스냅샷 저장, 실패 시 롤백, 서버 응답으로 최종 동기화—이 세 가지가 한 세트로 굴러가야 UI가 꼬이지 않죠. 제가 처음 짠 코드처럼 앞부분만 구현하면, 그건 낙관적 업데이트가 아니라 "실패를 못 본 척하는 업데이트"에 가깝습니다.
개인적으로는 좋아요 버튼 하나에서 이걸 아프게 배운 게 다행이었다고 봅니다. 결제나 재고처럼 어긋나면 곤란한 화면에서 같은 실수를 했다면, 하루가 아니라 훨씬 길게 헤맸을 테니까요. 지금은 낙관적 업데이트를 켜기 전에 스스로에게 꼭 한 번 묻습니다. "이 요청이 실패하면, 화면은 어디로 되돌아가지?" 이 물음에 바로 답할 수 있을 때에만 UI를 미리 그리기 시작하죠.