클릭보다 화면이 먼저 움직입니다 — 체감 속도 다듬기
공감을 누르면 서버보다 하트가 먼저 채워져요. 실패하면 그 자리에 재시도가 남고요. 기능 목록에 안 적히는 커밋들 — 낙관적 업데이트, CLS 제거, 잔잔한 다듬기 — 로 체감 속도를 다듬은 기록이에요.

기능 목록에는 안 적히는 커밋들이 있어요. 새 기능도 버그 수정도 아닌, "조금 더 빨라 보이게, 조금 덜 흔들리게"만을 위한 커밋들. 이번 편은 그 커밋들의 이야기예요.
체감 속도는 실제 속도와 다른 문제예요
먼저 이걸 갈라야 뒤 이야기가 됩니다. 같은 300ms도 어디에 있느냐에 따라 완전히 다르게 느껴져요.
| 지연이 있는 자리 | 사용자가 느끼는 것 | 고치는 방법 |
|---|---|---|
| 클릭 → 화면 반응 | 앱이 멈춤 | 낙관 반영 — 먼저 그리고 나중에 확인 |
| 화면 전환 | 느림 | 미리 가져오기 · 전환 애니메이션 |
| 콘텐츠 로딩 | 비어 있음 | 자리를 미리 차지하는 스켈레톤 |
| 로딩 끝난 뒤 레이아웃 이동 | 고장 난 느낌 | 높이를 미리 예약 |
첫 줄이 제일 커요. 사람은 대략 100ms 안에 반응이 없으면 "내가 누른 게 맞나"를 의심하기 시작하거든요. 그 의심이 한 번 생기면 실제로 200ms가 걸렸든 500ms가 걸렸든 똑같이 느립니다.
그래서 이번 편의 커밋들은 대부분 서버를 빠르게 만든 게 아니에요. 기다리는 자리를 옮긴 거예요.
클릭보다 화면이 먼저
공감을 누르면 이제 서버 응답을 기다리지 않고 하트가 먼저 채워져요. 댓글도, 구독도 마찬가지 — 입력은 즉시 화면에 반영되고, 서버 확인은 반 박자 뒤에 따라옵니다. 낙관적 업데이트라고 불러요. 화면이 사용자를 기다리게 하는 대신, 시스템이 화면을 따라잡는 구조예요.
낙관 반영은 말은 쉬운데 상태가 하나 더 늘어나는 일이에요.
// 이렇게 짜면 금방 엉킵니다
setLiked(true);
await api.like(postId); // 실패하면? 그 사이 또 누르면?
// 그래서 요청을 상태로 다룹니다
type Pending = {
intent: "like" | "unlike";
requestId: string; // 멱등 키
startedAt: number;
};
// 화면에 보이는 값 = 서버 값 + 대기 중인 의도
const shown = pending ? applyIntent(server, pending) : server;여기서 requestId가 핵심이에요. 같은 요청을 다시 보내도 두 번 처리되지 않게 하는 열쇠거든요. 이게 없으면 재시도가 위험해지고, 재시도가 위험하면 실패했을 때 할 수 있는 게 롤백밖에 없어요.
멱등을 미리 깔아 둔 게 여기서 배당금으로 돌아왔습니다. 그때는 서버 쪽 안전장치라고만 생각했는데, 실제로는 화면이 과감해질 수 있게 해 주는 장치였어요.
실패하면요? 조용히 원상복구하고 끝내지 않아요. 실패한 그 자리에 인라인 재시도가 남아요 — 쓰던 댓글, 옮기던 글, 어디까지 했는지를 잃지 않게 실패의 의도를 보존합니다. 이게 안전한 건 4편에서 깔아둔 멱등 설계 덕이에요. 같은 요청을 다시 보내도 두 번 처리되지 않으니, 낙관과 재시도가 마음 편히 공존해요.
실패했을 때 뭘 하느냐가 이 편에서 제일 신경 쓴 부분이에요.
| 실패 처리 | 사용자가 겪는 것 |
|---|---|
| 조용히 원상복구 | 내가 뭘 했는지도 모른 채 사라짐 — 최악 |
| 토스트로 알리고 원상복구 | 알긴 아는데 쓰던 걸 잃음 |
| 그 자리에 재시도를 남김 | 다시 누르면 이어짐 |
// 실패해도 의도를 버리지 않습니다
if (result.failed) {
setPending({ ...pending, state: "failed" });
// 쓰던 댓글 본문, 옮기던 위치가 pending 안에 그대로 남습니다
}
// 화면에는 그 자리에 재시도 버튼이 남고
// 같은 requestId 로 다시 보냅니다세 번째 줄을 고른 이유는 단순해요. 실패는 대부분 일시적이거든요. 지하철에서 터널을 지나는 중이라든가요. 그 몇 초 때문에 쓰던 댓글을 날리는 건 과한 벌입니다.
삭제처럼 무거운 동작에 낙관 반영을 붙이려면 조건이 하나 붙어요. 되돌릴 수 있어야 합니다.
// 목록에서 즉시 사라지되, 되돌릴 창을 엽니다
function deletePost(id) {
const removed = list.find(p => p.id === id);
setList(list.filter(p => p.id !== id)); // 즉시 사라짐
const timer = setTimeout(() => commitDelete(id), UNDO_WINDOW);
showUndo({
label: "글을 삭제했어요",
onUndo: () => {
clearTimeout(timer);
setList(list); // 원래 목록으로
},
});
}서버 요청을 되돌리기 창이 닫힌 뒤에 보내는 게 포인트예요. 보냈다가 취소하는 게 아니라, 아직 안 보낸 겁니다. 그래서 되돌리기가 실패할 수가 없어요.
| 방식 | 되돌리기 성공률 | 서버 부담 |
|---|---|---|
| 즉시 삭제 후 복구 API | 복구가 실패할 수 있음 | 삭제 + 복구 두 번 |
| 확인 대화상자 | 되돌릴 일이 없음 | 한 번 — 대신 매번 멈춤 |
| 지연 실행 + 되돌리기 창 | 항상 성공 | 한 번 |
세 번째가 좋은 이유는 사용자를 안 멈춰 세우면서도 안전하다는 거예요. 확인 대화상자는 안전한데, 열 번 중 아홉 번은 그냥 누르게 되니까 결국 안전장치 노릇을 못 합니다.
시리즈 순서 바꾸기도 같은 모양이에요. 드래그를 놓는 순간 화면에서는 바뀌고, 서버 저장은 조금 뒤에 한 번만 갑니다. 드래그하는 동안 매번 저장하면 요청이 수십 개 나가거든요.
낙관 반영은 반응에서 멈추지 않았어요. 글 삭제는 노션처럼 확인 즉시 목록에서 사라지고, 시리즈 순서는 행을 드래그하면 그 자리에서 바뀌어요. 뒤에서 서버가 따라오고요.
(낙관 UX가 늘어나니 인터랙션 코드도 정책/상태/명령 계층으로 갈라 레이어 검사를 붙였어요. 코어에서 하던 버릇 그대로예요.)
어디까지 낙관적으로 할까
낙관 반영을 아무 데나 붙이면 안 돼요. 되돌리기 어려운 일에 붙이면 거짓말이 됩니다.
| 동작 | 낙관 반영 | 이유 |
|---|---|---|
| 공감 · 구독 | 함 | 실패해도 손해가 없고 되돌리기 쉬움 |
| 댓글 작성 | 함 | 내용이 보존되면 재시도가 안전함 |
| 시리즈 순서 바꾸기 | 함 | 순서는 언제든 다시 바꿀 수 있음 |
| 글 삭제 | 함 (되돌리기 제공) | 목록에서 즉시 사라지되 취소할 수 있게 |
| 발행 | 안 함 | 남에게 보이기 시작하는 일이라 확인이 먼저 |
마지막 줄이 기준이에요. 결과가 나 혼자만의 것이면 먼저 그려도 되고, 남에게 나가는 일이면 확인부터 합니다.
그리고 낙관 반영이 늘어나면서 인터랙션 코드를 정책·상태·명령 계층으로 갈랐어요. 안 그러면 컴포넌트 하나가 "언제 낙관할지"와 "어떻게 그릴지"를 같이 알게 되거든요.
낙관 반영이 늘어나면서 컴포넌트가 너무 많은 걸 알게 됐어요. 그래서 셋으로 갈랐습니다.
// 정책 — "이 동작은 낙관적으로 해도 되는가"
const policy = {
like: { optimistic: true, undo: false },
comment: { optimistic: true, undo: false },
delete: { optimistic: true, undo: true },
publish: { optimistic: false, undo: false },
};
// 상태 — 대기 중인 의도를 들고 있음
// 명령 — 실제 요청을 보내고 결과를 반영
// 컴포넌트가 아는 것
const { shown, run } = useAction("like", subject);
<HeartButton filled={shown} onClick={run} />| 갈라 두지 않으면 | 갈라 두면 |
|---|---|
| 컴포넌트가 낙관 여부를 스스로 판단 | 정책 표 한 곳에서 결정 |
| 같은 동작이 화면마다 다르게 동작 | 어디서 부르든 같음 |
| 새 동작마다 낙관 로직을 다시 씀 | 표에 한 줄 추가 |
| 정책 변경 시 컴포넌트를 다 찾아야 함 | 표만 고침 |
네 번째 줄이 실제로 겪은 일이에요. "발행도 낙관적으로 해 볼까"를 검토하다가, 그러려면 어디를 고쳐야 하는지 찾을 수가 없었거든요. 그때 갈랐습니다.
코어에서 하던 버릇 그대로예요. 판단하는 곳과 그리는 곳을 갈라 두면, 판단이 바뀔 때 그리는 곳은 안 건드립니다.
흔들리지 않는 화면
빠른 것만큼 중요한 게 안 흔들리는 거예요. 글 본문이 로딩되는 동안 스켈레톤이 실제 콘텐츠와 같은 높이를 미리 차지해서, 로딩이 끝나도 화면이 털썩 내려앉지 않아요. 페이지 전환도 CSR 로딩 흐름으로 정리했고, 로그아웃 확인의 어색한 지연도 걷어냈어요.
흔들림을 잡는 건 대부분 높이 예약이에요.
/* 스켈레톤이 실제 콘텐츠와 같은 높이를 미리 차지합니다 */
.post-body-skeleton { min-height: var(--estimated-body-height); }
/* 이미지는 비율을 미리 알려 둡니다 */
.thumbnail { aspect-ratio: 16 / 9; }
/* 폰트가 늦게 와도 줄 높이가 안 바뀌게 */
.post-body { line-height: 1.75; }두 번째 줄이 제일 효과가 컸어요. 이미지 크기를 안 적어 두면 이미지가 도착하는 순간 아래 있던 글이 통째로 밀려 내려갑니다. 읽던 문장이 사라지는 거죠.
그리고 이건 "예쁘게 보이기"가 아니라 "읽던 자리를 지키기"의 문제예요. 화면이 한 번 튀면 사용자는 방금 어디를 읽고 있었는지 다시 찾아야 하거든요.
흔들림을 어떻게 재나
"안 흔들린다"도 눈으로 판정하면 안 돼요. 개발 기계에서는 로딩이 빨라서 흔들림이 안 보이거든요.
// 레이아웃 이동을 직접 관찰합니다
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // 사용자 조작에 의한 이동은 제외
console.warn("레이아웃 이동", entry.value, entry.sources);
}
});
observer.observe({ type: "layout-shift", buffered: true });`sources`에 어떤 요소가 밀렸는지가 들어 있어요. 그래서 "느낌상 튄다"가 아니라 "이 요소가 이만큼 밀었다"로 이야기할 수 있습니다.
| 원인 | 증상 | 처방 |
|---|---|---|
| 이미지 크기 미지정 | 이미지 도착 시 아래가 밀림 | 비율을 미리 선언 |
| 스켈레톤 높이가 실제와 다름 | 로딩 끝나면 털썩 | 예상 높이를 데이터로 계산 |
| 늦게 오는 폰트 | 줄 높이가 바뀜 | 줄 높이 고정 · 대체 폰트 지표 맞춤 |
| 조건부 배너 | 뒤늦게 나타나 전체를 밈 | 자리를 미리 비워 두거나 겹쳐 띄움 |
두 번째 줄은 스켈레톤을 대충 만들면 오히려 더 나빠지는 경우예요. 아무것도 없는 것보다, 잘못된 높이를 차지하다가 바뀌는 게 더 튑니다.
잔잔한 것들
| 다듬은 것 | 왜 |
|---|---|
| 시각 표기를 24시간제로 고정 | 서버와 브라우저가 다르게 표기하면 hydration이 어긋나요 |
| 댓글 조회 실패 안내를 유형별로 | "실패했습니다"보다 "지금 다시 눌러보세요"가 낫죠 |
| 링크 편집 경험·로딩 레이아웃 정리 | 자주 쓰는 길일수록 턱이 없어야 해요 |
| 상세 화면 정보 밀도 조정 | 읽는 밀도는 높게, 시각 소음은 낮게 |
(그리고 지금 레포의 최신 커밋은 "대표 이미지를 썸네일 스키마로 통합"이에요. 다음 배포부터 이 글의 썸네일도 그 스키마로 옮겨 갑니다.)
체감 최적화의 함정
정직하게 하나 적어 둘게요. 이 작업들이 문제를 가리는 효과도 있었습니다.
클릭이 빨라 보이니까 서버가 여전히 느리다는 걸 한동안 못 느꼈어요. 프리패치가 걸리지 않는 첫 진입에서는 원래 속도가 그대로 나오는데, 그 경로를 제가 자주 안 밟았거든요.
| 최적화한 경로 | 자주 밟는 사람 | 실제로 느린 경로 |
|---|---|---|
| 링크 클릭 후 전환 | 나 (계속 돌아다님) | 첫 진입 |
| 공감·댓글 반응 | 나 | — |
| 목록에서 상세로 | 나 | 검색 유입 → 상세 직행 |
오른쪽 열이 실제 독자의 경로예요. 검색으로 들어온 사람은 첫 화면이 곧 마지막 화면일 수도 있는데, 그 경로를 만든 사람이 제일 안 밟습니다.
나중에 첫 응답 시간을 재보고서야 알았어요. 그래서 다음 편에서 서버 쪽을 손봅니다.
체감 속도는 기다림을 견디게 해주고, 실제 속도는 기다림을 없애요. 둘 다 필요한데, 순서를 착각하면 진짜 문제를 늦게 발견하게 됩니다.
체감 속도로 할 수 있는 건 여기까지예요. 이건 전부 "이미 도착한 다음"을 다듬은 커밋들이거든요. 도착하기까지의 시간을 줄인 이야기는 다음 편에 이어집니다.


